Repository navigation
fix(web): name the missing loader script tag, verify web bundle in CI, fix README - #192
Merged
Merged
Conversation
) * feat: prepare v5 native agent upgrade * fix: address review feedback * fix: declare Flutter Swift package dependency * fix: keep example checks compatible with native v4 Native v4 responses use visitor_id and no longer provide the legacy extended payload. Run the example in basic mode and accept the native snake-case visitor identifier during the Dart API migration. Refs: INTER-2401
* docs: add v5 migration plan * docs: tighten v5 migration sequence * docs: include AGP 9 compatibility in v5 * docs: complete v5 migration plan * docs: clarify v5 error and support policy * docs: align v5 web result behavior * docs: define v5 tag contract * docs: align v5 scalar tag handling * docs: address v5 plan review feedback * docs: split PR 4 into three provable PRs * docs: tighten migration plan
* docs: add v5 migration plan * docs: tighten v5 migration sequence * docs: include AGP 9 compatibility in v5 * docs: complete v5 migration plan * docs: clarify v5 error and support policy * docs: align v5 web result behavior * docs: define v5 tag contract * docs: align v5 scalar tag handling * fix: migrate Android builds to built-in Kotlin * fix: default the example to legacy Kotlin application * docs: note the Kotlin plugin floor for AGP 8 apps * docs: name the native Android SDK version correctly * ci: build latest Flutter in both Kotlin modes Refs: INTER-2398
* chore: add AGENTS.md for coding agents Point CLAUDE.md at the same file so Claude Code picks up the same notes. * docs: update AGENTS.md to clarify principles and simplify language Revised the values section to emphasize simplicity and clear interfaces, enhancing the overall clarity of the document. * ci: skip Flutter checks on markdown-only PRs
* test: add cross-platform e2e smoke tests * docs: move smoke test instructions to contributing Pin the E2E iOS job to the 3.44.0 floor like the other jobs. * test: run the full example check suite in e2e Drop the E2E_SMOKE_TEST flag: all 12 checks pass locally on Chrome and an Android emulator in about 10-16s, so the example app no longer needs a test-only branch. * ci: build the e2e iOS example with Swift Package Manager Check out as fpjs_pro_plugin so SwiftPM resolves the path-overridden plugin (flutter#186881), instead of forcing CocoaPods, and assert SwiftPM picked the plugin up. Keep iOS on latest stable: pinned to 3.44.0 the example compiles but never starts on the simulator, so the run times out. * ci: retry the e2e smoke test once The flow makes a dozen live identification calls per platform. An iOS run failed on "NetworkError: The network connection was lost" after the checks had passed, so allow one retry. Two consecutive failures still fail the job. * ci: run secret-backed e2e jobs in the e2e environment Scope the identification key to these jobs instead of every workflow in the repository. check-secrets declares the environment too, so its gate still sees the key once the repository-level copies are removed. * ci: simplify the e2e workflow to three platform jobs Web, Android and iOS each need the identification key, and GitHub withholds secrets from fork pull requests, so gate the jobs on the head repository instead of probing for a key in a separate job. Move the example widget tests to ci.yml next to the other analyze and test work: they need no device and run on both Flutter versions there. Extract the .env.local writing and the single retry into scripts, so the emulator runner's sh and the other jobs' bash run the same code. * fix: space out the example checks Firing the checks back to back makes iOS reuse a connection the server has already closed, so the call after the burst fails with "NetworkError: The network connection was lost". An iOS smoke run reproduced it twice, on call 13, after all twelve checks had passed. * ci: drop the redundant example pub get The root `flutter pub get` already resolves the example, it reports "Got dependencies in ./example". * ci: read the e2e secrets from the repository again A job with an `environment` creates a deployment to it, so each run posted three deployment entries to the pull request and a failing job read as "had a problem deploying to e2e". Not worth the noise: the fork gate is what actually keeps the key away from untrusted code. * test: run the deliberate-timeout checks first A 5ms timeout cancels the request mid-flight (iOS passes it through as timeout: 0.005), and on iOS the call right after such a cancellation fails with "NetworkError: The network connection was lost". That is what an iOS smoke run hit twice on call 13, right after the two timeout checks. Running them first is a fix rather than pacing around them, so drop both the 500ms gap and the job level retry. Also key the raw check result and device ID so the test stops parsing display copy, drop the SwiftPM assertion that ci.yml already makes, fail early on an empty API_KEY, and name the versions behind the two pinned action SHAs. * ci: cache the toolchain and skip the live run when nothing it covers changed The E2E jobs spend about a minute each installing Flutter and the Android job creates and boots an emulator from scratch every time, so cache both. The live run also has nothing to say about a documentation only change. * ci: trigger the live run on web runtime changes too `web/index.js` is a declared asset of the plugin, so a change there is exactly what the web smoke test is for. Also drop the redundant `test` argument: `flutter test` already runs only the `test` directory. * test: fail the smoke test fast when initialization fails Without a failure predicate an initialization error burned the full 90 second wait and reported a generic timeout. Now it reports the actual error in about 13 seconds. * ci: drop the AVD cache Two warm runs put the smoke step at 327s and 296s against 339s without the cache, while restoring it costs 10 to 19s. That is inside the run to run noise, and the emulator still boots either way, so the cache only adds two steps to read. * ci: run the smoke test through flutter drive everywhere `flutter test` intermittently never launches the app on an iOS simulator and then waits with no output after "Xcode build done", which burned two 30 minute jobs here. That is flutter#153433, reported at about one run in ten, and `flutter drive` is the documented workaround. The web job already used it, so all three jobs now share one invocation. The step also gets a timeout so a stall cannot eat the whole job budget. * ci: launch the iOS smoke test on a known VM service URL `flutter drive` and `flutter test` both find the Dart VM service URL by scraping the simulator log, and they start the log stream without waiting for it to attach. When the app prints the URL first the line is lost and the tool waits forever, with no output after "Xcode build done". That is flutter#181771, still open and still present in 3.47.4, which is why the switch to `flutter drive` did not help. Build the app, launch it with `simctl` on a fixed port with auth codes off, wait for the service to answer, then point `flutter drive --use-existing-app` at it. The tool never reads the log. On failure the job now dumps the simulator log for the Runner process. * test: tolerate the platform semantics handle race on CI devices The framework takes a SemanticsHandle of its own when the platform turns accessibility on, and testWidgets records the handle count before the test body runs. CI simulators turn it on at an unpredictable moment, so when it lands mid-test the end-of-test check finds one handle more than the baseline and fails a run whose assertions all passed (flutter#153850). An iOS soak hit this in 5 of 12 runs. Two ways to order accessibility against the start of the test were tried and neither worked: forcing it on in the simulator before launch, and taking a handle in the app beforehand. Every run ends with the platform's handle present either way, so the arrival time is the only variable and nothing available controls it. Replace integrationDriver with an equivalent that exits 0 when every reported failure is that assertion, and 1 otherwise. A soak of 12 runs tolerated 4 races and passed 12 of 12. * test: require a single reported exception to tolerate the race The tolerance matched on substring alone, so it would have passed a details string holding the race and a real failure together. That shape cannot occur, because the binding skips its end-of-test invariant checks once an exception is pending, but the contract now holds structurally rather than by that coincidence. * test: prototype Maestro iOS smoke flow Drive the normal example app through native accessibility to avoid the Dart VM discovery and testWidgets semantics races. Expose three result identifiers while keeping the existing widget keys. See https://docs.maestro.dev/get-started/supported-platform/flutter. Five live iOS runs passed, and a deliberately wrong visitor ID failed the final comparison. Analyzer and all 12 example tests passed. * test: use shared Maestro smoke flow across all platforms Build the normal example app and run the original initialization, 12 SDK checks, identification, and JSON visitor-ID comparison on iOS, Android, and web. Replace the Flutter integration driver, fixed VM-service launch, and semantics-exception filtering with one Maestro flow and CI matrix. Use visible text because generated accessibility IDs differ across targets. Enable Flutter web's accessibility DOM as required by Maestro: https://docs.maestro.dev/get-started/supported-platform/flutter Web support remains beta and Chromium-only: https://docs.maestro.dev/get-started/supported-platform/web-browser Validated three fresh launches and a forced ID mismatch per platform, analysis and 25 unit/widget tests on Flutter 3.44.0 and stable 3.47.4, workflow lint, shell syntax, and whitespace checks. CI execution is unverified; all changes and the PR draft remain local. * test: dismiss the CI Quickstep launcher dialog The Android CI screenshot shows a Quickstep ANR dialog covering the initialized example app. Close only that system-launcher dialog before running the shared smoke checks. Example-app errors still fail the flow. Failure evidence: https://github.com/fingerprintjs/fingerprintjs-pro-flutter/actions/runs/35092770524 The normal Android flow passed locally with the conditional handler. * test: give each platform its own smoke test step Refs: INTER-2552
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines --------- Co-authored-by: Claude <noreply@anthropic.com>
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * docs: say why tags are validated in Dart Without the Dart walk, a bad value can crash the native bridge on one OS and be dropped on another. * chore: tweak agents.md --------- Co-authored-by: Claude <noreply@anthropic.com>
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * feat: add Pigeon native identification contract Replace the MethodChannel tuple with a config-keyed Pigeon get on Android and iOS. Public FpjsProPlugin API is unchanged. * fix: create Android Fingerprint clients atomically ConcurrentHashMap.getOrPut can run the factory twice on first access. computeIfAbsent keeps one client per configuration. * fix: create native Fingerprint clients during init initFpjs only stored Dart config. Native create runs now so location can warm before the first get. Identification still carries full config. * fix: preserve endpoint fallbacks and type cache keys Pigeon kept a single endpoints list, so fallbacks vanished without a primary URL. Cache identity is now a typed key, not a delimiter string. * fix: keep iOS fallbacks when no primary endpoint is set iOS Region only attaches fallbacks on .custom. With no primary URL, use the region's default endpoint as that custom domain so fallbacks still apply. Android already does this with endpoint ?: region.endpointUrl. https://docs.fingerprint.com/docs/ios-sdk * refactor: rename MethodChannelFingerprint to FingerprintNative Document cache keys, R8 error mapping, CompletionBox, and fallbacks without a primary endpoint. * docs: update contributing guidelines with architecture overview and Pigeon usage * docs: label native, Pigeon, and Dart result shapes getVisitorId maps empty visitorId to null without building FingerprintResult. * docs: explain Unknown eventId sentinel and unwrapError aliases * fix: keep JSON null tag values on Android Dart validateTags accepts null. pigeonTagsToNative dropped those entries, so {'campaign': null} reached the SDK as {}. * fix: keep JSON null tags on iOS The v4 iOS SDK already has JSONType.null. The converter dropped nil and NSNull, and nested Flutter maps typed as [AnyHashable: Any]. * fix: keep iOS boolean tags as JSON bools Flutter sends bools as CFBoolean NSNumbers. Casting to Int first turned true into 1. Checking Bool first would also turn integer 0/1 into bools. * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * refactor: make tag conversion a free function JSONTypeConvertor was only a namespace for static methods. * fix: read inner iOS network error text FPError is not LocalizedError, so localizedDescription is Foundation's generic string. The wrapped URLError's localizedDescription is the user-facing message. --------- Co-authored-by: Claude <noreply@anthropic.com>
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * feat: add Pigeon native identification contract Replace the MethodChannel tuple with a config-keyed Pigeon get on Android and iOS. Public FpjsProPlugin API is unchanged. * fix: create Android Fingerprint clients atomically ConcurrentHashMap.getOrPut can run the factory twice on first access. computeIfAbsent keeps one client per configuration. * fix: create native Fingerprint clients during init initFpjs only stored Dart config. Native create runs now so location can warm before the first get. Identification still carries full config. * fix: preserve endpoint fallbacks and type cache keys Pigeon kept a single endpoints list, so fallbacks vanished without a primary URL. Cache identity is now a typed key, not a delimiter string. * fix: keep iOS fallbacks when no primary endpoint is set iOS Region only attaches fallbacks on .custom. With no primary URL, use the region's default endpoint as that custom domain so fallbacks still apply. Android already does this with endpoint ?: region.endpointUrl. https://docs.fingerprint.com/docs/ios-sdk * refactor: rename MethodChannelFingerprint to FingerprintNative Document cache keys, R8 error mapping, CompletionBox, and fallbacks without a primary endpoint. * docs: update contributing guidelines with architecture overview and Pigeon usage * docs: label native, Pigeon, and Dart result shapes getVisitorId maps empty visitorId to null without building FingerprintResult. * docs: explain Unknown eventId sentinel and unwrapError aliases * fix: keep JSON null tag values on Android Dart validateTags accepts null. pigeonTagsToNative dropped those entries, so {'campaign': null} reached the SDK as {}. * fix: keep JSON null tags on iOS The v4 iOS SDK already has JSONType.null. The converter dropped nil and NSNull, and nested Flutter maps typed as [AnyHashable: Any]. * fix: keep iOS boolean tags as JSON bools Flutter sends bools as CFBoolean NSNumbers. Casting to Int first turned true into 1. Checking Bool first would also turn integer 0/1 into bools. * feat: swap the public API to Fingerprint and web v4 The static plugin and v3 web agent cannot express the v4 contract. Instance Fingerprint carries config on every get so two clients stay independent. Web uses @fingerprint/agent start/get. * fix: do not pass null into the web agent get JS default parameters apply to undefined only. get(null) throws when the agent reads options.timeout. The Chrome error test now throws a JS object instead of boxing a Future rejection. * fix: keep web cache presets distinct and drop the stale Yarn lock Dart const-canonicalized optimizeCost and aggressive to the same object, so aggressive was sent as optimize-cost. Custom durations also truncated leftover milliseconds to whole seconds. * docs: say native region defaults to US when omitted The constructor comment claimed the agent infers region from the API key. That is only true on web. * docs: document the Fingerprint constructor and get API Reviewers can see the public API in the README diff. PR 6 stays the post-rename migration guide. * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * fix: drop public ready and report create errors from get Web start() is synchronous, so ready could not wait for the bundle. Native create still starts in the constructor. The example status now says the Dart client was created. * fix: reject typed lists in tags Uint8List is a List, so validation accepted it. Pigeon sends typed data, and iOS drops the tag instead of storing a JSON array. * fix: clamp Android timeouts that overflow Int Long.toInt() wraps, so a huge timeout becomes negative and the SDK treats it as already expired. Clamp to Int range, same as RN Double.toInt(). https://docs.fingerprint.com/docs/android-sdk * fix: collapse platform-only error codes into shared ones Web network_connection/network_abort and Android NetworkUnavailableError become network_error. Codes identification never returns fall through to unknown_error. https://docs.fingerprint.com/reference/js-agent-error-handling * test: drop duplicate platform-interface suite Public Fingerprint tests already cover the same mocked create/get forwarding. * refactor: make tag conversion a free function JSONTypeConvertor was only a namespace for static methods. * fix: read inner iOS network error text FPError is not LocalizedError, so localizedDescription is Foundation's generic string. The wrapped URLError's localizedDescription is the user-facing message. * chore: changesets * fix: treat empty endpoints as the regional default Match the JS agent: [] means omit custom endpoints. README now calls web/index.js a loader and says to create the client at startup. * fix: drop empty strings from endpoints An empty first item would become a missing iOS primary and invent the region URL. Filter blanks, then treat an empty list as the default. * chore: tweak readme * fix: type web suspect_score as an optional int JSAny was only needed for sealed_result, which is a string or BinaryOutput. * docs: clarify config equality and omitted web get options * docs: rename the example endpoint env var to ENDPOINTS * chore: rename the example endpoint field to _endpoints * refactor: share one identify helper in the example app * refactor: drop unreachable fallbacks-without-primary path Public endpoints always send a primary when any URL remains, so the native special case and its test never ran for consumers. * fix: stop treating .changeset/AGENTS.md as a changeset Changesets parses every markdown file in that folder except README.md. * chore: drop leftover SCRIPT_URL_PATTERN from e2e scriptUrlPattern is no longer a public option, and the example never reads it. * docs: say custom cache duration is whole seconds The JS agent takes a number of seconds, not a Dart Duration's sub-second remainder. * fix: forward unlisted iOS API error codes Keep unused server codes out of Dart constants. If iOS still sends one, pass the snake_case raw value through for debugging. * fix: disable JS agent default endpoint fallbacks * chore: leave max 12 hours validation to JS agent * fix: reject a negative get timeout Dart Duration can be negative. Validate once for native and web, and rename tags.dart to validation.dart. * fix: validate get arguments before create Keep get() non-async so ArgumentError throws now, not as a Future error after create. Native and web just forward. * docs: clarify region defaults and unused Android errors Web infers region from the API key. Remaining Android Error types are not identification errors, so they stay unknown_error. * fix: retry a failed create and wrap all native errors - A failed create was kept forever, so every later get failed the same way. Now the next get retries it. - Native only mapped PlatformException. Other errors (e.g. a missing Flutter binding) escaped get as raw exceptions. They now become unknown_error FingerprintError with the original text as message. * fix: name unmapped Android errors when they have no description Android types without a Dart code are sent as unknown_error. When the SDK gives only its "Unknown" default description, the message was null, so developers saw nothing. Fall back to the type name. * fix: map Pigeon channel errors to unknown_error - channel-error and null-error come from Pigeon, not native SDK code, so they are not documented codes. Send unknown_error and keep the Pigeon text in the message. - Inline unwrapError into its only caller. * refactor: filter empty endpoints only in Dart Fingerprint already drops empty strings and turns an empty list into null. Android, iOS, and the example app no longer repeat it. * test: cover validation through get and web error paths - Tag validation tests now call Fingerprint.get, like a consumer. - Drop result tests that only read back a constructor field. - Web: JS error without a code, and a throw from start. - Native: Pigeon channel error. * docs: clarify cache maximum, config equality, and module overviews - The JS agent checks the 12 hour cache maximum, so the limit can change without a plugin release. - FingerprintConfig equality exists because it keys cached web agents and native clients. - README shows the cache keyPrefix option. * refactor: stop waiting for create in get - The constructor still calls create early, so on Android and iOS the native client, and location collection, start as soon as possible. - get no longer waits for that create. Platform get already creates the client when missing (native getOrCreate, web _agentFor), so waiting only added a round trip and a failed create that could get stuck. - This drops the retry state. A failed early create no longer breaks get, and real create errors still surface from get. --------- Co-authored-by: Claude <noreply@anthropic.com>
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * feat: add Pigeon native identification contract Replace the MethodChannel tuple with a config-keyed Pigeon get on Android and iOS. Public FpjsProPlugin API is unchanged. * fix: create Android Fingerprint clients atomically ConcurrentHashMap.getOrPut can run the factory twice on first access. computeIfAbsent keeps one client per configuration. * fix: create native Fingerprint clients during init initFpjs only stored Dart config. Native create runs now so location can warm before the first get. Identification still carries full config. * fix: preserve endpoint fallbacks and type cache keys Pigeon kept a single endpoints list, so fallbacks vanished without a primary URL. Cache identity is now a typed key, not a delimiter string. * fix: keep iOS fallbacks when no primary endpoint is set iOS Region only attaches fallbacks on .custom. With no primary URL, use the region's default endpoint as that custom domain so fallbacks still apply. Android already does this with endpoint ?: region.endpointUrl. https://docs.fingerprint.com/docs/ios-sdk * refactor: rename MethodChannelFingerprint to FingerprintNative Document cache keys, R8 error mapping, CompletionBox, and fallbacks without a primary endpoint. * docs: update contributing guidelines with architecture overview and Pigeon usage * docs: label native, Pigeon, and Dart result shapes getVisitorId maps empty visitorId to null without building FingerprintResult. * docs: explain Unknown eventId sentinel and unwrapError aliases * fix: keep JSON null tag values on Android Dart validateTags accepts null. pigeonTagsToNative dropped those entries, so {'campaign': null} reached the SDK as {}. * fix: keep JSON null tags on iOS The v4 iOS SDK already has JSONType.null. The converter dropped nil and NSNull, and nested Flutter maps typed as [AnyHashable: Any]. * fix: keep iOS boolean tags as JSON bools Flutter sends bools as CFBoolean NSNumbers. Casting to Int first turned true into 1. Checking Bool first would also turn integer 0/1 into bools. * feat: swap the public API to Fingerprint and web v4 The static plugin and v3 web agent cannot express the v4 contract. Instance Fingerprint carries config on every get so two clients stay independent. Web uses @fingerprint/agent start/get. * fix: do not pass null into the web agent get JS default parameters apply to undefined only. get(null) throws when the agent reads options.timeout. The Chrome error test now throws a JS object instead of boxing a Future rejection. * fix: keep web cache presets distinct and drop the stale Yarn lock Dart const-canonicalized optimizeCost and aggressive to the same object, so aggressive was sent as optimize-cost. Custom durations also truncated leftover milliseconds to whole seconds. * docs: say native region defaults to US when omitted The constructor comment claimed the agent infers region from the API key. That is only true on web. * docs: document the Fingerprint constructor and get API Reviewers can see the public API in the README diff. PR 6 stays the post-rename migration guide. * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * fix: drop public ready and report create errors from get Web start() is synchronous, so ready could not wait for the bundle. Native create still starts in the constructor. The example status now says the Dart client was created. * fix: reject typed lists in tags Uint8List is a List, so validation accepted it. Pigeon sends typed data, and iOS drops the tag instead of storing a JSON array. * fix: clamp Android timeouts that overflow Int Long.toInt() wraps, so a huge timeout becomes negative and the SDK treats it as already expired. Clamp to Int range, same as RN Double.toInt(). https://docs.fingerprint.com/docs/android-sdk * fix: collapse platform-only error codes into shared ones Web network_connection/network_abort and Android NetworkUnavailableError become network_error. Codes identification never returns fall through to unknown_error. https://docs.fingerprint.com/reference/js-agent-error-handling * test: drop duplicate platform-interface suite Public Fingerprint tests already cover the same mocked create/get forwarding. * refactor: make tag conversion a free function JSONTypeConvertor was only a namespace for static methods. * fix: read inner iOS network error text FPError is not LocalizedError, so localizedDescription is Foundation's generic string. The wrapped URLError's localizedDescription is the user-facing message. * chore: changesets * test: lock get contracts the suite missed validateTags only ran in isolation, so dropping it from get still passed. Web get() vs get(null) was untested. Result equality only compared suspectScore. https://docs.fingerprint.com/reference/js-agent-get-function * test: pin Android HostApi custom endpoint, location, linkedId, timeout Omitted-default tests stayed green if those live values were ignored. * test: pin omitted location off and distinct web agents Default location true, or dropping web from FingerprintConfig equality, stayed green. * chore: changesets * fix: treat empty endpoints as the regional default Match the JS agent: [] means omit custom endpoints. README now calls web/index.js a loader and says to create the client at startup. * fix: drop empty strings from endpoints An empty first item would become a missing iOS primary and invent the region URL. Filter blanks, then treat an empty list as the default. * chore: tweak readme * fix: type web suspect_score as an optional int JSAny was only needed for sealed_result, which is a string or BinaryOutput. * docs: clarify config equality and omitted web get options * docs: rename the example endpoint env var to ENDPOINTS * chore: rename the example endpoint field to _endpoints * refactor: share one identify helper in the example app * refactor: drop unreachable fallbacks-without-primary path Public endpoints always send a primary when any URL remains, so the native special case and its test never ran for consumers. * fix: stop treating .changeset/AGENTS.md as a changeset Changesets parses every markdown file in that folder except README.md. * chore: drop leftover SCRIPT_URL_PATTERN from e2e scriptUrlPattern is no longer a public option, and the example never reads it. * docs: say custom cache duration is whole seconds The JS agent takes a number of seconds, not a Dart Duration's sub-second remainder. * fix: forward unlisted iOS API error codes Keep unused server codes out of Dart constants. If iOS still sends one, pass the snake_case raw value through for debugging. * fix: restore stacked branch CI Co-authored-by: JuroUhlar <11249993+JuroUhlar@users.noreply.github.com> * fix: disable JS agent default endpoint fallbacks * fix: restore indentation after merge conflict resolution Co-authored-by: JuroUhlar <11249993+JuroUhlar@users.noreply.github.com> * test: keep coverage on public contracts Reuse two separately constructed configs in the web cache test. Keep Android region and endpoint cases aligned with Dart inputs, and restore the test import lost during conflict resolution. * chore: leave max 12 hours validation to JS agent * fix: reject a negative get timeout Dart Duration can be negative. Validate once for native and web, and rename tags.dart to validation.dart. * fix: validate get arguments before create Keep get() non-async so ArgumentError throws now, not as a Future error after create. Native and web just forward. * docs: clarify region defaults and unused Android errors Web infers region from the API key. Remaining Android Error types are not identification errors, so they stay unknown_error. * fix: retry a failed create and wrap all native errors - A failed create was kept forever, so every later get failed the same way. Now the next get retries it. - Native only mapped PlatformException. Other errors (e.g. a missing Flutter binding) escaped get as raw exceptions. They now become unknown_error FingerprintError with the original text as message. * fix: name unmapped Android errors when they have no description Android types without a Dart code are sent as unknown_error. When the SDK gives only its "Unknown" default description, the message was null, so developers saw nothing. Fall back to the type name. * fix: map Pigeon channel errors to unknown_error - channel-error and null-error come from Pigeon, not native SDK code, so they are not documented codes. Send unknown_error and keep the Pigeon text in the message. - Inline unwrapError into its only caller. * refactor: filter empty endpoints only in Dart Fingerprint already drops empty strings and turns an empty list into null. Android, iOS, and the example app no longer repeat it. * test: cover validation through get and web error paths - Tag validation tests now call Fingerprint.get, like a consumer. - Drop result tests that only read back a constructor field. - Web: JS error without a code, and a throw from start. - Native: Pigeon channel error. * docs: clarify cache maximum, config equality, and module overviews - The JS agent checks the 12 hour cache maximum, so the limit can change without a plugin release. - FingerprintConfig equality exists because it keys cached web agents and native clients. - README shows the cache keyPrefix option. * refactor: stop waiting for create in get - The constructor still calls create early, so on Android and iOS the native client, and location collection, start as soon as possible. - get no longer waits for that create. Platform get already creates the client when missing (native getOrCreate, web _agentFor), so waiting only added a round trip and a failed create that could get stuck. - This drops the retry state. A failed early create no longer breaks get, and real create errors still surface from get. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JuroUhlar <11249993+JuroUhlar@users.noreply.github.com> Refs: INTER-2591
- Android: [4.1.0, 4.2.0). 4.1.0 has a threads NPE fix and a smaller build. - iOS: upToNextMinor(from: "4.0.0"), and the podspec follows it. 4.1.0 is not released yet. - Same patch-only policy as the 4.x plugin on main. A minor that shipped with a problem cannot be pulled from users once a floating range allows it, e.g. iOS 2.8.0 and App Store rejections: #92
* fix: drop visitor_not_found from the public error codes - It is not an identification error, like ruleset_not_found and the secret API key codes, which already have no constant. - Android now sends it as unknown_error and iOS passes the raw snake_case code through, the same path those codes take. * fix: rename WebCache.keyPrefix to cachePrefix Same name as the JS agent cache option and the React Native SDK, so the option maps one to one. https://docs.fingerprint.com/reference/js-agent-start-function
SDK 4.1.0's obfuscated NetworkError initializer throws IllegalAccessError on the desktop JVM.
* test: add iOS XCTest coverage matching Android Host the tests in example/ios/RunnerTests so they can import the plugin and stub the native SDK. Also keep nulls in iOS tag arrays and drop Unknown error placeholders. https://docs.flutter.dev/testing/testing-plugins * test: run iOS unit tests under SwiftPM only and tighten checks - Run XCTest once, under SwiftPM, in its own DerivedData. Plugin sources are the same for both integrations. A shared DerivedData let RunnerTests link frameworks left over from the CocoaPods build. - Drop the RunnerTests CocoaPods target from the Podfile and project. - Cache test: compare all configurations with each other, so a ClientKey that ignores the custom domain or fallbacks fails. - JSONTypeConvertor: treat a nil optional boxed in Any as JSON null, instead of relying on cast order. Collapse map casts into one. - Drop the iOS "Unknown" message filter. No source shows the iOS SDK uses that placeholder. The message comes from the server response. - Drop the changeset. The fixed code never shipped. - contributing.md: pick any simulator, enable SwiftPM first. - Add overview comments to the touched plugin files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ZtMdagodn2va4yLMfT21g * test: update iOS host API tests after the v5 merge Create with a named region and no endpoint is .eu, not a synthesized custom URL. Also assert timeoutMs is forwarded in seconds. * test: migrate iOS unit tests to Swift Testing - Swift Testing is Apple's replacement for XCTest and ships with Xcode 16, which the plugin already requires. - Error code and event ID cases are parameterized, so a failure names the case instead of a line inside a loop. - Region is Equatable, so region checks compare values instead of pattern matching. - contributing.md: new tests use Swift Testing. https://developer.apple.com/documentation/testing/migratingfromxctest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: skip NetworkError in JVM error mapping test Port of #170 so this PR is green before v5 carries it. SDK 4.1.0's obfuscated NetworkError static initializer throws IllegalAccessError on the desktop JVM. ART allows it, so production is unaffected. network_error stays covered by NetworkUnavailableError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ZtMdagodn2va4yLMfT21g --------- Co-authored-by: Claude <noreply@anthropic.com>
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * feat: add Pigeon native identification contract Replace the MethodChannel tuple with a config-keyed Pigeon get on Android and iOS. Public FpjsProPlugin API is unchanged. * fix: create Android Fingerprint clients atomically ConcurrentHashMap.getOrPut can run the factory twice on first access. computeIfAbsent keeps one client per configuration. * fix: create native Fingerprint clients during init initFpjs only stored Dart config. Native create runs now so location can warm before the first get. Identification still carries full config. * fix: preserve endpoint fallbacks and type cache keys Pigeon kept a single endpoints list, so fallbacks vanished without a primary URL. Cache identity is now a typed key, not a delimiter string. * fix: keep iOS fallbacks when no primary endpoint is set iOS Region only attaches fallbacks on .custom. With no primary URL, use the region's default endpoint as that custom domain so fallbacks still apply. Android already does this with endpoint ?: region.endpointUrl. https://docs.fingerprint.com/docs/ios-sdk * refactor: rename MethodChannelFingerprint to FingerprintNative Document cache keys, R8 error mapping, CompletionBox, and fallbacks without a primary endpoint. * docs: update contributing guidelines with architecture overview and Pigeon usage * docs: label native, Pigeon, and Dart result shapes getVisitorId maps empty visitorId to null without building FingerprintResult. * docs: explain Unknown eventId sentinel and unwrapError aliases * fix: keep JSON null tag values on Android Dart validateTags accepts null. pigeonTagsToNative dropped those entries, so {'campaign': null} reached the SDK as {}. * fix: keep JSON null tags on iOS The v4 iOS SDK already has JSONType.null. The converter dropped nil and NSNull, and nested Flutter maps typed as [AnyHashable: Any]. * fix: keep iOS boolean tags as JSON bools Flutter sends bools as CFBoolean NSNumbers. Casting to Int first turned true into 1. Checking Bool first would also turn integer 0/1 into bools. * feat: swap the public API to Fingerprint and web v4 The static plugin and v3 web agent cannot express the v4 contract. Instance Fingerprint carries config on every get so two clients stay independent. Web uses @fingerprint/agent start/get. * fix: do not pass null into the web agent get JS default parameters apply to undefined only. get(null) throws when the agent reads options.timeout. The Chrome error test now throws a JS object instead of boxing a Future rejection. * fix: keep web cache presets distinct and drop the stale Yarn lock Dart const-canonicalized optimizeCost and aggressive to the same object, so aggressive was sent as optimize-cost. Custom durations also truncated leftover milliseconds to whole seconds. * docs: say native region defaults to US when omitted The constructor comment claimed the agent infers region from the API key. That is only true on web. * docs: document the Fingerprint constructor and get API Reviewers can see the public API in the README diff. PR 6 stays the post-rename migration guide. * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * fix: drop public ready and report create errors from get Web start() is synchronous, so ready could not wait for the bundle. Native create still starts in the constructor. The example status now says the Dart client was created. * fix: reject typed lists in tags Uint8List is a List, so validation accepted it. Pigeon sends typed data, and iOS drops the tag instead of storing a JSON array. * fix: clamp Android timeouts that overflow Int Long.toInt() wraps, so a huge timeout becomes negative and the SDK treats it as already expired. Clamp to Int range, same as RN Double.toInt(). https://docs.fingerprint.com/docs/android-sdk * fix: collapse platform-only error codes into shared ones Web network_connection/network_abort and Android NetworkUnavailableError become network_error. Codes identification never returns fall through to unknown_error. https://docs.fingerprint.com/reference/js-agent-error-handling * test: drop duplicate platform-interface suite Public Fingerprint tests already cover the same mocked create/get forwarding. * refactor: make tag conversion a free function JSONTypeConvertor was only a namespace for static methods. * fix: read inner iOS network error text FPError is not LocalizedError, so localizedDescription is Foundation's generic string. The wrapped URLError's localizedDescription is the user-facing message. * chore: changesets * fix: treat empty endpoints as the regional default Match the JS agent: [] means omit custom endpoints. README now calls web/index.js a loader and says to create the client at startup. * fix: drop empty strings from endpoints An empty first item would become a missing iOS primary and invent the region URL. Filter blanks, then treat an empty list as the default. * chore: tweak readme * fix: type web suspect_score as an optional int JSAny was only needed for sealed_result, which is a string or BinaryOutput. * docs: clarify config equality and omitted web get options * docs: rename the example endpoint env var to ENDPOINTS * chore: rename the example endpoint field to _endpoints * refactor: share one identify helper in the example app * refactor: drop unreachable fallbacks-without-primary path Public endpoints always send a primary when any URL remains, so the native special case and its test never ran for consumers. * fix: stop treating .changeset/AGENTS.md as a changeset Changesets parses every markdown file in that folder except README.md. * chore: drop leftover SCRIPT_URL_PATTERN from e2e scriptUrlPattern is no longer a public option, and the example never reads it. * docs: say custom cache duration is whole seconds The JS agent takes a number of seconds, not a Dart Duration's sub-second remainder. * fix: forward unlisted iOS API error codes Keep unused server codes out of Dart constants. If iOS still sends one, pass the snake_case raw value through for debugging. * feat: rename package to fingerprint_flutter INTER-2400. New pub name so a 5.0.0 alpha can install as fingerprint_flutter. * fix: keep integrationInfo as fingerprint-pro-flutter Server analytics still keys off the old integration name. * docs: point live repo links at fingerprintjs/flutter GitHub will be renamed to flutter when this lands on main. CHANGELOG commit URLs stay; those redirect after the rename. * fix: disable JS agent default endpoint fallbacks * fix: restore SwiftPM product name Flutter converts pub package underscores to hyphens when it generates SwiftPM product dependencies. Keep the target name underscored, but expose fingerprint-flutter so generated iOS projects resolve it. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: clarify SwiftPM product scope The hyphenated product name is required only by Flutters generated native build. Flutter consumers still depend on and import fingerprint_flutter. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * chore: leave max 12 hours validation to JS agent * fix: reject a negative get timeout Dart Duration can be negative. Validate once for native and web, and rename tags.dart to validation.dart. * fix: validate get arguments before create Keep get() non-async so ArgumentError throws now, not as a Future error after create. Native and web just forward. * docs: clarify region defaults and unused Android errors Web infers region from the API key. Remaining Android Error types are not identification errors, so they stay unknown_error. * fix: retry a failed create and wrap all native errors - A failed create was kept forever, so every later get failed the same way. Now the next get retries it. - Native only mapped PlatformException. Other errors (e.g. a missing Flutter binding) escaped get as raw exceptions. They now become unknown_error FingerprintError with the original text as message. * fix: name unmapped Android errors when they have no description Android types without a Dart code are sent as unknown_error. When the SDK gives only its "Unknown" default description, the message was null, so developers saw nothing. Fall back to the type name. * fix: map Pigeon channel errors to unknown_error - channel-error and null-error come from Pigeon, not native SDK code, so they are not documented codes. Send unknown_error and keep the Pigeon text in the message. - Inline unwrapError into its only caller. * refactor: filter empty endpoints only in Dart Fingerprint already drops empty strings and turns an empty list into null. Android, iOS, and the example app no longer repeat it. * test: cover validation through get and web error paths - Tag validation tests now call Fingerprint.get, like a consumer. - Drop result tests that only read back a constructor field. - Web: JS error without a code, and a throw from start. - Native: Pigeon channel error. * docs: clarify cache maximum, config equality, and module overviews - The JS agent checks the 12 hour cache maximum, so the limit can change without a plugin release. - FingerprintConfig equality exists because it keys cached web agents and native clients. - README shows the cache keyPrefix option. * refactor: stop waiting for create in get - The constructor still calls create early, so on Android and iOS the native client, and location collection, start as soon as possible. - get no longer waits for that create. Platform get already creates the client when missing (native getOrCreate, web _agentFor), so waiting only added a round trip and a failed create that could get stuck. - This drops the retry state. A failed early create no longer breaks get, and real create errors still surface from get. * test: skip NetworkError in JVM error mapping test SDK 4.1.0's obfuscated NetworkError initializer throws IllegalAccessError on the desktop JVM. * test: point merged iOS tests at fingerprint_flutter - v5 added the iOS unit tests before the package rename. Update the test imports and the CI working directory to the new package name. - Rename FPJSErrorFlutterTests to FPErrorFlutterTests to match FPError+Flutter.swift. * chore: use changesets-native-dependency-format 0.2.0 0.1.0 hard-codes the FingerprintPro pod name. The v4 iOS SDK pod is Fingerprint-iOS, so the native version note would fail to generate. 0.2.0 takes the pod name and gradle task in nested ios/android config. * docs: link issue #92 via the renamed repo --------- Co-authored-by: Claude <noreply@anthropic.com>
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * feat: add Pigeon native identification contract Replace the MethodChannel tuple with a config-keyed Pigeon get on Android and iOS. Public FpjsProPlugin API is unchanged. * fix: create Android Fingerprint clients atomically ConcurrentHashMap.getOrPut can run the factory twice on first access. computeIfAbsent keeps one client per configuration. * fix: create native Fingerprint clients during init initFpjs only stored Dart config. Native create runs now so location can warm before the first get. Identification still carries full config. * fix: preserve endpoint fallbacks and type cache keys Pigeon kept a single endpoints list, so fallbacks vanished without a primary URL. Cache identity is now a typed key, not a delimiter string. * fix: keep iOS fallbacks when no primary endpoint is set iOS Region only attaches fallbacks on .custom. With no primary URL, use the region's default endpoint as that custom domain so fallbacks still apply. Android already does this with endpoint ?: region.endpointUrl. https://docs.fingerprint.com/docs/ios-sdk * refactor: rename MethodChannelFingerprint to FingerprintNative Document cache keys, R8 error mapping, CompletionBox, and fallbacks without a primary endpoint. * docs: update contributing guidelines with architecture overview and Pigeon usage * docs: label native, Pigeon, and Dart result shapes getVisitorId maps empty visitorId to null without building FingerprintResult. * docs: explain Unknown eventId sentinel and unwrapError aliases * fix: keep JSON null tag values on Android Dart validateTags accepts null. pigeonTagsToNative dropped those entries, so {'campaign': null} reached the SDK as {}. * fix: keep JSON null tags on iOS The v4 iOS SDK already has JSONType.null. The converter dropped nil and NSNull, and nested Flutter maps typed as [AnyHashable: Any]. * fix: keep iOS boolean tags as JSON bools Flutter sends bools as CFBoolean NSNumbers. Casting to Int first turned true into 1. Checking Bool first would also turn integer 0/1 into bools. * feat: swap the public API to Fingerprint and web v4 The static plugin and v3 web agent cannot express the v4 contract. Instance Fingerprint carries config on every get so two clients stay independent. Web uses @fingerprint/agent start/get. * fix: do not pass null into the web agent get JS default parameters apply to undefined only. get(null) throws when the agent reads options.timeout. The Chrome error test now throws a JS object instead of boxing a Future rejection. * fix: keep web cache presets distinct and drop the stale Yarn lock Dart const-canonicalized optimizeCost and aggressive to the same object, so aggressive was sent as optimize-cost. Custom durations also truncated leftover milliseconds to whole seconds. * docs: say native region defaults to US when omitted The constructor comment claimed the agent infers region from the API key. That is only true on web. * docs: document the Fingerprint constructor and get API Reviewers can see the public API in the README diff. PR 6 stays the post-rename migration guide. * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * fix: drop public ready and report create errors from get Web start() is synchronous, so ready could not wait for the bundle. Native create still starts in the constructor. The example status now says the Dart client was created. * fix: reject typed lists in tags Uint8List is a List, so validation accepted it. Pigeon sends typed data, and iOS drops the tag instead of storing a JSON array. * fix: clamp Android timeouts that overflow Int Long.toInt() wraps, so a huge timeout becomes negative and the SDK treats it as already expired. Clamp to Int range, same as RN Double.toInt(). https://docs.fingerprint.com/docs/android-sdk * fix: collapse platform-only error codes into shared ones Web network_connection/network_abort and Android NetworkUnavailableError become network_error. Codes identification never returns fall through to unknown_error. https://docs.fingerprint.com/reference/js-agent-error-handling * test: drop duplicate platform-interface suite Public Fingerprint tests already cover the same mocked create/get forwarding. * refactor: make tag conversion a free function JSONTypeConvertor was only a namespace for static methods. * fix: read inner iOS network error text FPError is not LocalizedError, so localizedDescription is Foundation's generic string. The wrapped URLError's localizedDescription is the user-facing message. * chore: changesets * fix: treat empty endpoints as the regional default Match the JS agent: [] means omit custom endpoints. README now calls web/index.js a loader and says to create the client at startup. * fix: drop empty strings from endpoints An empty first item would become a missing iOS primary and invent the region URL. Filter blanks, then treat an empty list as the default. * chore: tweak readme * fix: type web suspect_score as an optional int JSAny was only needed for sealed_result, which is a string or BinaryOutput. * docs: clarify config equality and omitted web get options * docs: rename the example endpoint env var to ENDPOINTS * chore: rename the example endpoint field to _endpoints * refactor: share one identify helper in the example app * refactor: drop unreachable fallbacks-without-primary path Public endpoints always send a primary when any URL remains, so the native special case and its test never ran for consumers. * fix: stop treating .changeset/AGENTS.md as a changeset Changesets parses every markdown file in that folder except README.md. * chore: drop leftover SCRIPT_URL_PATTERN from e2e scriptUrlPattern is no longer a public option, and the example never reads it. * docs: say custom cache duration is whole seconds The JS agent takes a number of seconds, not a Dart Duration's sub-second remainder. * fix: forward unlisted iOS API error codes Keep unused server codes out of Dart constants. If iOS still sends one, pass the snake_case raw value through for debugging. * feat: rename package to fingerprint_flutter INTER-2400. New pub name so a 5.0.0 alpha can install as fingerprint_flutter. * fix: keep integrationInfo as fingerprint-pro-flutter Server analytics still keys off the old integration name. * docs: point live repo links at fingerprintjs/flutter GitHub will be renamed to flutter when this lands on main. CHANGELOG commit URLs stay; those redirect after the rename. * fix: disable JS agent default endpoint fallbacks * docs: write the 5.0.0 release notes Replace the internal migration plan and split changesets with one consumer migration guide. * docs: print visitorId and eventId in the migration snippet * docs: link the 4.x upgrade note to the changelog * chore: enter pre-release mode * fix: restore SwiftPM product name Flutter converts pub package underscores to hyphens when it generates SwiftPM product dependencies. Keep the target name underscored, but expose fingerprint-flutter so generated iOS projects resolve it. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: clarify SwiftPM product scope The hyphenated product name is required only by Flutters generated native build. Flutter consumers still depend on and import fingerprint_flutter. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: shorten the 5.0.0 changeset * chore: leave max 12 hours validation to JS agent * fix: reject a negative get timeout Dart Duration can be negative. Validate once for native and web, and rename tags.dart to validation.dart. * fix: validate get arguments before create Keep get() non-async so ArgumentError throws now, not as a Future error after create. Native and web just forward. * docs: clarify region defaults and unused Android errors Web infers region from the API key. Remaining Android Error types are not identification errors, so they stay unknown_error. * fix: retry a failed create and wrap all native errors - A failed create was kept forever, so every later get failed the same way. Now the next get retries it. - Native only mapped PlatformException. Other errors (e.g. a missing Flutter binding) escaped get as raw exceptions. They now become unknown_error FingerprintError with the original text as message. * fix: name unmapped Android errors when they have no description Android types without a Dart code are sent as unknown_error. When the SDK gives only its "Unknown" default description, the message was null, so developers saw nothing. Fall back to the type name. * fix: map Pigeon channel errors to unknown_error - channel-error and null-error come from Pigeon, not native SDK code, so they are not documented codes. Send unknown_error and keep the Pigeon text in the message. - Inline unwrapError into its only caller. * refactor: filter empty endpoints only in Dart Fingerprint already drops empty strings and turns an empty list into null. Android, iOS, and the example app no longer repeat it. * test: cover validation through get and web error paths - Tag validation tests now call Fingerprint.get, like a consumer. - Drop result tests that only read back a constructor field. - Web: JS error without a code, and a throw from start. - Native: Pigeon channel error. * docs: clarify cache maximum, config equality, and module overviews - The JS agent checks the 12 hour cache maximum, so the limit can change without a plugin release. - FingerprintConfig equality exists because it keys cached web agents and native clients. - README shows the cache keyPrefix option. * refactor: stop waiting for create in get - The constructor still calls create early, so on Android and iOS the native client, and location collection, start as soon as possible. - get no longer waits for that create. Platform get already creates the client when missing (native getOrCreate, web _agentFor), so waiting only added a round trip and a failed create that could get stuck. - This drops the retry state. A failed early create no longer breaks get, and real create errors still surface from get. * docs: note PlatformException catch no longer works in v5 * docs: list v5 behavior changes in the migration notes - Tags must be JSON-compatible. Invalid tags and a negative timeout throw ArgumentError before identifying. - Custom endpoints still get no automatic fallback, unlike the JS agent v4 default. - Android and iOS still default to the US region. * docs: list the Kotlin Gradle plugin floor in README requirements The plugin's Kotlin code is compiled by the app's Kotlin version, and the native Android SDK is built with Kotlin 2.3 metadata. AGP 8 apps with an older Kotlin Gradle plugin fail to build. The changeset already said this. * fix: throw from the constructor when the Flutter binding is missing (#164) * fix: throw from the constructor when the Flutter binding is missing On Android and iOS the constructor starts the native client right away. Without a binding, that failed inside the stored create future: - the early native create (location warm-up) never ran - every get() rethrew a FlutterError, not a FingerprintError - the client never recovered Read ServicesBinding.instance before anything async, so the standard "call WidgetsFlutterBinding.ensureInitialized()" error is thrown where the client is created. The README now shows that call in main(). https://api.flutter.dev/flutter/widgets/WidgetsFlutterBinding/ensureInitialized.html Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgsmqW19NX6duqtNNc9wzq * chore: tweak readme --------- Co-authored-by: Claude <noreply@anthropic.com> * test: skip NetworkError in JVM error mapping test SDK 4.1.0's obfuscated NetworkError initializer throws IllegalAccessError on the desktop JVM. * fix: name the missing-binding FlutterError ServicesBinding.instance names ServicesBinding, and release builds throw a null-check. The constructor now throws FlutterError that says to call WidgetsFlutterBinding.ensureInitialized(). * test: point merged iOS tests at fingerprint_flutter - v5 added the iOS unit tests before the package rename. Update the test imports and the CI working directory to the new package name. - Rename FPJSErrorFlutterTests to FPErrorFlutterTests to match FPError+Flutter.swift. --------- Co-authored-by: Claude <noreply@anthropic.com>
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * feat: add Pigeon native identification contract Replace the MethodChannel tuple with a config-keyed Pigeon get on Android and iOS. Public FpjsProPlugin API is unchanged. * fix: create Android Fingerprint clients atomically ConcurrentHashMap.getOrPut can run the factory twice on first access. computeIfAbsent keeps one client per configuration. * fix: create native Fingerprint clients during init initFpjs only stored Dart config. Native create runs now so location can warm before the first get. Identification still carries full config. * fix: preserve endpoint fallbacks and type cache keys Pigeon kept a single endpoints list, so fallbacks vanished without a primary URL. Cache identity is now a typed key, not a delimiter string. * fix: keep iOS fallbacks when no primary endpoint is set iOS Region only attaches fallbacks on .custom. With no primary URL, use the region's default endpoint as that custom domain so fallbacks still apply. Android already does this with endpoint ?: region.endpointUrl. https://docs.fingerprint.com/docs/ios-sdk * refactor: rename MethodChannelFingerprint to FingerprintNative Document cache keys, R8 error mapping, CompletionBox, and fallbacks without a primary endpoint. * docs: update contributing guidelines with architecture overview and Pigeon usage * docs: label native, Pigeon, and Dart result shapes getVisitorId maps empty visitorId to null without building FingerprintResult. * docs: explain Unknown eventId sentinel and unwrapError aliases * fix: keep JSON null tag values on Android Dart validateTags accepts null. pigeonTagsToNative dropped those entries, so {'campaign': null} reached the SDK as {}. * fix: keep JSON null tags on iOS The v4 iOS SDK already has JSONType.null. The converter dropped nil and NSNull, and nested Flutter maps typed as [AnyHashable: Any]. * fix: keep iOS boolean tags as JSON bools Flutter sends bools as CFBoolean NSNumbers. Casting to Int first turned true into 1. Checking Bool first would also turn integer 0/1 into bools. * feat: swap the public API to Fingerprint and web v4 The static plugin and v3 web agent cannot express the v4 contract. Instance Fingerprint carries config on every get so two clients stay independent. Web uses @fingerprint/agent start/get. * fix: do not pass null into the web agent get JS default parameters apply to undefined only. get(null) throws when the agent reads options.timeout. The Chrome error test now throws a JS object instead of boxing a Future rejection. * fix: keep web cache presets distinct and drop the stale Yarn lock Dart const-canonicalized optimizeCost and aggressive to the same object, so aggressive was sent as optimize-cost. Custom durations also truncated leftover milliseconds to whole seconds. * docs: say native region defaults to US when omitted The constructor comment claimed the agent infers region from the API key. That is only true on web. * docs: document the Fingerprint constructor and get API Reviewers can see the public API in the README diff. PR 6 stays the post-rename migration guide. * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * fix: drop public ready and report create errors from get Web start() is synchronous, so ready could not wait for the bundle. Native create still starts in the constructor. The example status now says the Dart client was created. * fix: reject typed lists in tags Uint8List is a List, so validation accepted it. Pigeon sends typed data, and iOS drops the tag instead of storing a JSON array. * fix: clamp Android timeouts that overflow Int Long.toInt() wraps, so a huge timeout becomes negative and the SDK treats it as already expired. Clamp to Int range, same as RN Double.toInt(). https://docs.fingerprint.com/docs/android-sdk * fix: collapse platform-only error codes into shared ones Web network_connection/network_abort and Android NetworkUnavailableError become network_error. Codes identification never returns fall through to unknown_error. https://docs.fingerprint.com/reference/js-agent-error-handling * test: drop duplicate platform-interface suite Public Fingerprint tests already cover the same mocked create/get forwarding. * refactor: make tag conversion a free function JSONTypeConvertor was only a namespace for static methods. * fix: read inner iOS network error text FPError is not LocalizedError, so localizedDescription is Foundation's generic string. The wrapped URLError's localizedDescription is the user-facing message. * chore: changesets * fix: treat empty endpoints as the regional default Match the JS agent: [] means omit custom endpoints. README now calls web/index.js a loader and says to create the client at startup. * fix: drop empty strings from endpoints An empty first item would become a missing iOS primary and invent the region URL. Filter blanks, then treat an empty list as the default. * chore: tweak readme * fix: type web suspect_score as an optional int JSAny was only needed for sealed_result, which is a string or BinaryOutput. * docs: clarify config equality and omitted web get options * docs: rename the example endpoint env var to ENDPOINTS * chore: rename the example endpoint field to _endpoints * refactor: share one identify helper in the example app * refactor: drop unreachable fallbacks-without-primary path Public endpoints always send a primary when any URL remains, so the native special case and its test never ran for consumers. * fix: stop treating .changeset/AGENTS.md as a changeset Changesets parses every markdown file in that folder except README.md. * chore: drop leftover SCRIPT_URL_PATTERN from e2e scriptUrlPattern is no longer a public option, and the example never reads it. * docs: say custom cache duration is whole seconds The JS agent takes a number of seconds, not a Dart Duration's sub-second remainder. * fix: forward unlisted iOS API error codes Keep unused server codes out of Dart constants. If iOS still sends one, pass the snake_case raw value through for debugging. * feat: rename package to fingerprint_flutter INTER-2400. New pub name so a 5.0.0 alpha can install as fingerprint_flutter. * fix: keep integrationInfo as fingerprint-pro-flutter Server analytics still keys off the old integration name. * docs: point live repo links at fingerprintjs/flutter GitHub will be renamed to flutter when this lands on main. CHANGELOG commit URLs stay; those redirect after the rename. * fix: disable JS agent default endpoint fallbacks * docs: write the 5.0.0 release notes Replace the internal migration plan and split changesets with one consumer migration guide. * docs: print visitorId and eventId in the migration snippet * docs: link the 4.x upgrade note to the changelog * chore: enter pre-release mode * fix: restore SwiftPM product name Flutter converts pub package underscores to hyphens when it generates SwiftPM product dependencies. Keep the target name underscored, but expose fingerprint-flutter so generated iOS projects resolve it. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: clarify SwiftPM product scope The hyphenated product name is required only by Flutters generated native build. Flutter consumers still depend on and import fingerprint_flutter. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: shorten the 5.0.0 changeset * chore: leave max 12 hours validation to JS agent * fix: reject a negative get timeout Dart Duration can be negative. Validate once for native and web, and rename tags.dart to validation.dart. * fix: validate get arguments before create Keep get() non-async so ArgumentError throws now, not as a Future error after create. Native and web just forward. * docs: clarify region defaults and unused Android errors Web infers region from the API key. Remaining Android Error types are not identification errors, so they stay unknown_error. * fix: retry a failed create and wrap all native errors - A failed create was kept forever, so every later get failed the same way. Now the next get retries it. - Native only mapped PlatformException. Other errors (e.g. a missing Flutter binding) escaped get as raw exceptions. They now become unknown_error FingerprintError with the original text as message. * fix: name unmapped Android errors when they have no description Android types without a Dart code are sent as unknown_error. When the SDK gives only its "Unknown" default description, the message was null, so developers saw nothing. Fall back to the type name. * fix: map Pigeon channel errors to unknown_error - channel-error and null-error come from Pigeon, not native SDK code, so they are not documented codes. Send unknown_error and keep the Pigeon text in the message. - Inline unwrapError into its only caller. * refactor: filter empty endpoints only in Dart Fingerprint already drops empty strings and turns an empty list into null. Android, iOS, and the example app no longer repeat it. * test: cover validation through get and web error paths - Tag validation tests now call Fingerprint.get, like a consumer. - Drop result tests that only read back a constructor field. - Web: JS error without a code, and a throw from start. - Native: Pigeon channel error. * docs: clarify cache maximum, config equality, and module overviews - The JS agent checks the 12 hour cache maximum, so the limit can change without a plugin release. - FingerprintConfig equality exists because it keys cached web agents and native clients. - README shows the cache keyPrefix option. * refactor: stop waiting for create in get - The constructor still calls create early, so on Android and iOS the native client, and location collection, start as soon as possible. - get no longer waits for that create. Platform get already creates the client when missing (native getOrCreate, web _agentFor), so waiting only added a round trip and a failed create that could get stuck. - This drops the retry state. A failed early create no longer breaks get, and real create errors still surface from get. * docs: note PlatformException catch no longer works in v5 * docs: list v5 behavior changes in the migration notes - Tags must be JSON-compatible. Invalid tags and a negative timeout throw ArgumentError before identifying. - Custom endpoints still get no automatic fallback, unlike the JS agent v4 default. - Android and iOS still default to the US region. * docs: list the Kotlin Gradle plugin floor in README requirements The plugin's Kotlin code is compiled by the app's Kotlin version, and the native Android SDK is built with Kotlin 2.3 metadata. AGP 8 apps with an older Kotlin Gradle plugin fail to build. The changeset already said this. * fix: throw from the constructor when the Flutter binding is missing (#164) * fix: throw from the constructor when the Flutter binding is missing On Android and iOS the constructor starts the native client right away. Without a binding, that failed inside the stored create future: - the early native create (location warm-up) never ran - every get() rethrew a FlutterError, not a FingerprintError - the client never recovered Read ServicesBinding.instance before anything async, so the standard "call WidgetsFlutterBinding.ensureInitialized()" error is thrown where the client is created. The README now shows that call in main(). https://api.flutter.dev/flutter/widgets/WidgetsFlutterBinding/ensureInitialized.html Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgsmqW19NX6duqtNNc9wzq * chore: tweak readme --------- Co-authored-by: Claude <noreply@anthropic.com> * test: skip NetworkError in JVM error mapping test SDK 4.1.0's obfuscated NetworkError initializer throws IllegalAccessError on the desktop JVM. * fix: name the missing-binding FlutterError ServicesBinding.instance names ServicesBinding, and release builds throw a null-check. The constructor now throws FlutterError that says to call WidgetsFlutterBinding.ensureInitialized(). * fix: run native create and get off the main thread Pigeon ran both host API methods on the platform main thread. On Android, the first client creation loads the SDK classes on the calling thread (~170 ms on an emulator, more on slow devices). Apps create the client at startup, so this blocked the UI thread during launch. - Both methods use one serial background task queue - get must move too: it waits on the client cache lock while create runs, and builds the client itself if create was skipped - A serial queue keeps create before a get sent right after it https://pub.dev/packages/pigeon#task-queue https://docs.flutter.dev/platform-integration/platform-channels#channels-and-platform-threading * docs: explain what the early client start does on each platform The docs said creating the client warms location. That only holds on iOS: - iOS: creating the client starts location collection. The iOS SDK docs recommend creating it at app startup and keeping one instance for the best location precision. - Android: creating the client loads the SDK, so the first get is faster. Location is collected during each get, up to locationTimeout. https://docs.fingerprint.com/docs/ios-sdk#using-location-data-for-proximity-detection https://docs.fingerprint.com/docs/android-sdk * docs: shorten Pigeon host API comments * docs: simplify README client and location notes * test: point merged iOS tests at fingerprint_flutter - v5 added the iOS unit tests before the package rename. Update the test imports and the CI working directory to the new package name. - Rename FPJSErrorFlutterTests to FPErrorFlutterTests to match FPError+Flutter.swift. --------- Co-authored-by: Claude <noreply@anthropic.com> Refs: INTER-2596
…eon config (#172) * refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * feat: add Pigeon native identification contract Replace the MethodChannel tuple with a config-keyed Pigeon get on Android and iOS. Public FpjsProPlugin API is unchanged. * fix: create Android Fingerprint clients atomically ConcurrentHashMap.getOrPut can run the factory twice on first access. computeIfAbsent keeps one client per configuration. * fix: create native Fingerprint clients during init initFpjs only stored Dart config. Native create runs now so location can warm before the first get. Identification still carries full config. * fix: preserve endpoint fallbacks and type cache keys Pigeon kept a single endpoints list, so fallbacks vanished without a primary URL. Cache identity is now a typed key, not a delimiter string. * fix: keep iOS fallbacks when no primary endpoint is set iOS Region only attaches fallbacks on .custom. With no primary URL, use the region's default endpoint as that custom domain so fallbacks still apply. Android already does this with endpoint ?: region.endpointUrl. https://docs.fingerprint.com/docs/ios-sdk * refactor: rename MethodChannelFingerprint to FingerprintNative Document cache keys, R8 error mapping, CompletionBox, and fallbacks without a primary endpoint. * docs: update contributing guidelines with architecture overview and Pigeon usage * docs: label native, Pigeon, and Dart result shapes getVisitorId maps empty visitorId to null without building FingerprintResult. * docs: explain Unknown eventId sentinel and unwrapError aliases * fix: keep JSON null tag values on Android Dart validateTags accepts null. pigeonTagsToNative dropped those entries, so {'campaign': null} reached the SDK as {}. * fix: keep JSON null tags on iOS The v4 iOS SDK already has JSONType.null. The converter dropped nil and NSNull, and nested Flutter maps typed as [AnyHashable: Any]. * fix: keep iOS boolean tags as JSON bools Flutter sends bools as CFBoolean NSNumbers. Casting to Int first turned true into 1. Checking Bool first would also turn integer 0/1 into bools. * feat: swap the public API to Fingerprint and web v4 The static plugin and v3 web agent cannot express the v4 contract. Instance Fingerprint carries config on every get so two clients stay independent. Web uses @fingerprint/agent start/get. * fix: do not pass null into the web agent get JS default parameters apply to undefined only. get(null) throws when the agent reads options.timeout. The Chrome error test now throws a JS object instead of boxing a Future rejection. * fix: keep web cache presets distinct and drop the stale Yarn lock Dart const-canonicalized optimizeCost and aggressive to the same object, so aggressive was sent as optimize-cost. Custom durations also truncated leftover milliseconds to whole seconds. * docs: say native region defaults to US when omitted The constructor comment claimed the agent infers region from the API key. That is only true on web. * docs: document the Fingerprint constructor and get API Reviewers can see the public API in the README diff. PR 6 stays the post-rename migration guide. * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * fix: drop public ready and report create errors from get Web start() is synchronous, so ready could not wait for the bundle. Native create still starts in the constructor. The example status now says the Dart client was created. * fix: reject typed lists in tags Uint8List is a List, so validation accepted it. Pigeon sends typed data, and iOS drops the tag instead of storing a JSON array. * fix: clamp Android timeouts that overflow Int Long.toInt() wraps, so a huge timeout becomes negative and the SDK treats it as already expired. Clamp to Int range, same as RN Double.toInt(). https://docs.fingerprint.com/docs/android-sdk * fix: collapse platform-only error codes into shared ones Web network_connection/network_abort and Android NetworkUnavailableError become network_error. Codes identification never returns fall through to unknown_error. https://docs.fingerprint.com/reference/js-agent-error-handling * test: drop duplicate platform-interface suite Public Fingerprint tests already cover the same mocked create/get forwarding. * refactor: make tag conversion a free function JSONTypeConvertor was only a namespace for static methods. * fix: read inner iOS network error text FPError is not LocalizedError, so localizedDescription is Foundation's generic string. The wrapped URLError's localizedDescription is the user-facing message. * chore: changesets * fix: treat empty endpoints as the regional default Match the JS agent: [] means omit custom endpoints. README now calls web/index.js a loader and says to create the client at startup. * fix: drop empty strings from endpoints An empty first item would become a missing iOS primary and invent the region URL. Filter blanks, then treat an empty list as the default. * chore: tweak readme * fix: type web suspect_score as an optional int JSAny was only needed for sealed_result, which is a string or BinaryOutput. * docs: clarify config equality and omitted web get options * docs: rename the example endpoint env var to ENDPOINTS * chore: rename the example endpoint field to _endpoints * refactor: share one identify helper in the example app * refactor: drop unreachable fallbacks-without-primary path Public endpoints always send a primary when any URL remains, so the native special case and its test never ran for consumers. * fix: stop treating .changeset/AGENTS.md as a changeset Changesets parses every markdown file in that folder except README.md. * chore: drop leftover SCRIPT_URL_PATTERN from e2e scriptUrlPattern is no longer a public option, and the example never reads it. * docs: say custom cache duration is whole seconds The JS agent takes a number of seconds, not a Dart Duration's sub-second remainder. * fix: forward unlisted iOS API error codes Keep unused server codes out of Dart constants. If iOS still sends one, pass the snake_case raw value through for debugging. * feat: rename package to fingerprint_flutter INTER-2400. New pub name so a 5.0.0 alpha can install as fingerprint_flutter. * fix: keep integrationInfo as fingerprint-pro-flutter Server analytics still keys off the old integration name. * docs: point live repo links at fingerprintjs/flutter GitHub will be renamed to flutter when this lands on main. CHANGELOG commit URLs stay; those redirect after the rename. * fix: disable JS agent default endpoint fallbacks * docs: write the 5.0.0 release notes Replace the internal migration plan and split changesets with one consumer migration guide. * docs: print visitorId and eventId in the migration snippet * docs: link the 4.x upgrade note to the changelog * chore: enter pre-release mode * fix: restore SwiftPM product name Flutter converts pub package underscores to hyphens when it generates SwiftPM product dependencies. Keep the target name underscored, but expose fingerprint-flutter so generated iOS projects resolve it. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: clarify SwiftPM product scope The hyphenated product name is required only by Flutters generated native build. Flutter consumers still depend on and import fingerprint_flutter. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: shorten the 5.0.0 changeset * chore: leave max 12 hours validation to JS agent * fix: reject a negative get timeout Dart Duration can be negative. Validate once for native and web, and rename tags.dart to validation.dart. * fix: validate get arguments before create Keep get() non-async so ArgumentError throws now, not as a Future error after create. Native and web just forward. * docs: clarify region defaults and unused Android errors Web infers region from the API key. Remaining Android Error types are not identification errors, so they stay unknown_error. * fix: retry a failed create and wrap all native errors - A failed create was kept forever, so every later get failed the same way. Now the next get retries it. - Native only mapped PlatformException. Other errors (e.g. a missing Flutter binding) escaped get as raw exceptions. They now become unknown_error FingerprintError with the original text as message. * fix: name unmapped Android errors when they have no description Android types without a Dart code are sent as unknown_error. When the SDK gives only its "Unknown" default description, the message was null, so developers saw nothing. Fall back to the type name. * fix: map Pigeon channel errors to unknown_error - channel-error and null-error come from Pigeon, not native SDK code, so they are not documented codes. Send unknown_error and keep the Pigeon text in the message. - Inline unwrapError into its only caller. * refactor: filter empty endpoints only in Dart Fingerprint already drops empty strings and turns an empty list into null. Android, iOS, and the example app no longer repeat it. * test: cover validation through get and web error paths - Tag validation tests now call Fingerprint.get, like a consumer. - Drop result tests that only read back a constructor field. - Web: JS error without a code, and a throw from start. - Native: Pigeon channel error. * docs: clarify cache maximum, config equality, and module overviews - The JS agent checks the 12 hour cache maximum, so the limit can change without a plugin release. - FingerprintConfig equality exists because it keys cached web agents and native clients. - README shows the cache keyPrefix option. * refactor: stop waiting for create in get - The constructor still calls create early, so on Android and iOS the native client, and location collection, start as soon as possible. - get no longer waits for that create. Platform get already creates the client when missing (native getOrCreate, web _agentFor), so waiting only added a round trip and a failed create that could get stuck. - This drops the retry state. A failed early create no longer breaks get, and real create errors still surface from get. * docs: note PlatformException catch no longer works in v5 * docs: list v5 behavior changes in the migration notes - Tags must be JSON-compatible. Invalid tags and a negative timeout throw ArgumentError before identifying. - Custom endpoints still get no automatic fallback, unlike the JS agent v4 default. - Android and iOS still default to the US region. * docs: list the Kotlin Gradle plugin floor in README requirements The plugin's Kotlin code is compiled by the app's Kotlin version, and the native Android SDK is built with Kotlin 2.3 metadata. AGP 8 apps with an older Kotlin Gradle plugin fail to build. The changeset already said this. * fix: throw from the constructor when the Flutter binding is missing (#164) * fix: throw from the constructor when the Flutter binding is missing On Android and iOS the constructor starts the native client right away. Without a binding, that failed inside the stored create future: - the early native create (location warm-up) never ran - every get() rethrew a FlutterError, not a FingerprintError - the client never recovered Read ServicesBinding.instance before anything async, so the standard "call WidgetsFlutterBinding.ensureInitialized()" error is thrown where the client is created. The README now shows that call in main(). https://api.flutter.dev/flutter/widgets/WidgetsFlutterBinding/ensureInitialized.html Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgsmqW19NX6duqtNNc9wzq * chore: tweak readme --------- Co-authored-by: Claude <noreply@anthropic.com> * test: skip NetworkError in JVM error mapping test SDK 4.1.0's obfuscated NetworkError initializer throws IllegalAccessError on the desktop JVM. * fix: name the missing-binding FlutterError ServicesBinding.instance names ServicesBinding, and release builds throw a null-check. The constructor now throws FlutterError that says to call WidgetsFlutterBinding.ensureInitialized(). * fix: run native create and get off the main thread Pigeon ran both host API methods on the platform main thread. On Android, the first client creation loads the SDK classes on the calling thread (~170 ms on an emulator, more on slow devices). Apps create the client at startup, so this blocked the UI thread during launch. - Both methods use one serial background task queue - get must move too: it waits on the client cache lock while create runs, and builds the client itself if create was skipped - A serial queue keeps create before a get sent right after it https://pub.dev/packages/pigeon#task-queue https://docs.flutter.dev/platform-integration/platform-channels#channels-and-platform-threading * docs: explain what the early client start does on each platform The docs said creating the client warms location. That only holds on iOS: - iOS: creating the client starts location collection. The iOS SDK docs recommend creating it at app startup and keeping one instance for the best location precision. - Android: creating the client loads the SDK, so the first get is faster. Location is collected during each get, up to locationTimeout. https://docs.fingerprint.com/docs/ios-sdk#using-location-data-for-proximity-detection https://docs.fingerprint.com/docs/android-sdk * docs: shorten Pigeon host API comments * docs: simplify README client and location notes * refactor!: make fingerprint_flutter.dart the only public library error.dart and result.dart only re-exported src/, and options.dart and region.dart were importable on their own. Apps only need the main library, so move the rest under src/ and export it from there. Dart treats lib/src as private to the package. https://dart.dev/tools/pub/package-layout#implementation-files * refactor!: stop exporting pluginVersion It is an internal value sent as integration info, not something apps should read. Move it to lib/src/plugin_version.dart, and read it directly on each platform instead of passing it through FingerprintConfig, where it never differs between clients. * refactor!: send region to native code as a Pigeon enum Region went to native code as a lowercase string, so Android and iOS each parsed it again and disagreed on bad input: Android threw, iOS fell back to US. A Pigeon enum makes bad input impossible and removes both parsers and the public RegionValue.stringValue extension. Web uses Region.name. Dart now sends US when region is omitted, the documented Android and iOS default. https://pub.dev/packages/pigeon#supported-datatypes * refactor: key native clients on the Pigeon config Pigeon generates value equality for FingerprintNativeConfig (Kotlin equals/hashCode, Swift Hashable), so it can be the cache key directly. This removes the hand-written ClientKey types, which copied every config field and had to be kept in sync. With the key gone, each cache was a map and one lookup, so it moves into FingerprintHostApiImpl. buildConfiguration now runs only when a client is created, not on every get. Configs that only resolve to the same SDK Configuration (no endpoint vs the region's default URL, or iOS with a different Android-only locationTimeoutMillis) now get separate clients. That is harmless. * docs: note the smaller public API in the v5 changeset * refactor: keep FingerprintClientCache as its own class Restore the cache as a small class keyed on FingerprintNativeConfig, and move buildConfiguration into it. FingerprintHostApiImpl only maps get calls, and the cache owns turning a config into one SDK client. * feat!: rename pluginVersion to fingerprintFlutterVersion Keep the package version public so apps can log it or add it to support requests. The new name says which package it belongs to. It is exported from the main library. * test: point merged iOS tests at fingerprint_flutter - v5 added the iOS unit tests before the package rename. Update the test imports and the CI working directory to the new package name. - Rename FPJSErrorFlutterTests to FPErrorFlutterTests to match FPError+Flutter.swift. * refactor(android): use the SDK default location timeout Configuration.DEFAULT_LOCATION_TIMEOUT_MILLIS is public, so the plugin follows the SDK default instead of repeating 5000. * fix(android): complete get with an error if client creation throws Pigeon catches throws only in sync handlers like create. In async get a throw would escape the handler instead of completing the Dart Future. Pigeon would also send a plain exception with its class name as code and a stack trace in details, which Dart reads as eventId. Send unknown_error instead. * test(android): test Configuration mapping through FingerprintClientCache The cache builds the Configuration, so its tests cover the mapping. --------- Co-authored-by: Claude <noreply@anthropic.com>
* refactor!: one error code per client failure on every platform
- Client failures use the JS agent code on all platforms:
- bad_response_format: Android ResponseCannotBeParsed, iOS
jsonParsingError and invalidResponseType
- invalid_endpoint: iOS invalidURL. Android reports a malformed endpoint
as a network error, so it stays network_error there.
- iOS invalidURLParams becomes unknown_error. The plugin builds the
integration info, so it is a plugin bug, not an app error.
https://docs.fingerprint.com/reference/js-agent-v4-error-handling
- Drop constants no platform can send through the plugin: the old client
codes above, handle_agent_data, api_key_invalid, wrong_worker_option
(typed options rule them out) and bundle_not_defined (loader internal).
The strings still pass through if they ever arrive.
- iOS forwards APIError.Code.rawValue, which is already the server's
snake_case code. This removes the per-code list and the camelCase helper.
- Document that on Android and iOS the native SDK turns server codes it
does not know into its unknown error and drops the server message. The
plugin cannot recover it. Android keeps the eventId.
* refactor: keep native client codes consistent with React Native
- Keep invalid_url (iOS), json_parsing_error (iOS) and
response_cannot_be_parsed (Android). The React Native SDK uses the same
codes, so both SDKs stay alike.
- iOS invalidURLParams and invalidResponseType go to unknown_error with
their message. Both are practically unreachable.
- Web constants are now the codes on the public JS agent error page, minus
codes for options the plugin does not expose. Drops server_error and
worker_initialization_failed, which that page does not list.
https://docs.fingerprint.com/reference/js-agent-v4-error-handling
- Fix comments that did not match the code and trim repeated ones.
* docs: explain why iOS forwards rawValue and how unlisted codes differ
- iOS SDK decodes the server code into APIError.Code, so rawValue is the
exact server string. That is why the plugin needs no per-code mapping.
- Dartdoc now says iOS and web pass unlisted server codes through, while
Android sends unknown_error with the message (no code string to forward).
* fix: keep iOS invalid_url_params and invalid_response_type codes
The iOS SDK's public FPError declares both cases. Whether they are thrown
today is not a public contract, so map them to their own codes, the same
as the React Native SDK, instead of folding them into unknown_error.
* fix: keep server_error and worker_initialization_failed constants
Both are in the JS agent's public ErrorCode type. Missing from the error
docs page is not a promise they are never thrown, so keep them. Only drop
codes the plugin itself rules out or the type marks as loader-internal.
https://docs.fingerprint.com/reference/js-agent-v4-error-handling
* chore: shorten readme
* refactor: shorten error comments and fold UnknownError into else
- Android UnknownError and Server API only types both became
unknown_error, so one branch covers both. An UnknownError without a
description now gets its type name as message, like the others.
- Trim the error comments and changeset line.
* docs: drop the error pass-through sentence from the changeset
It covered edge cases that do not affect migration. The FingerprintError Dartdoc documents them.
* chore: agents.md
* test: iOS forwards the server's error code string as is
Decode APIError from literal server JSON, not from APIError.Code.rawValue,
so the test fails if the plugin ever sends the Swift case name or any
other transform instead of the server's code.
…#174) * refactor: map errors to FingerprintError only in get The Fingerprint constructor ignores the create future, so mapping its errors was dead code. get already wraps every failure, including a failed web agent start, so the web code needs one catch, not two. Also fix the FingerprintConfig equality note: only the web agent cache uses Dart equality. Native code keys clients on FingerprintNativeConfig. * refactor: build the Fingerprint config once The public fields become getters over one final FingerprintConfig, so endpoints are normalized in the initializer and _config no longer needs late. * chore: tidy the Android plugin and pubspec - FingerprintPlugin.hostApiImpl was never read. Pigeon keeps the handler alive until setUp(null). - @nonnull has no effect on Kotlin parameters. - Dart rejects negative timeouts, so the Int clamp only needs an upper bound. - Drop the plugin template comments from pubspec.yaml. meta stays: the generated Pigeon Dart code imports it. * refactor(ios): inline the event id check The "Unknown" placeholder is an Android SDK default. iOS APIError.eventId is a non-optional String decoded from the server response, so only an empty id needs to become nil. https://docs.fingerprint.com/docs/ios-sdk * test: drop duplicate web start failure test get covers start failures in 'maps a throw from start and retries', and plain-value throws in 'maps a JS value without a code'. Also say the create doc means the returned future: a missing Flutter binding still throws from the Fingerprint constructor. * test(ios): expect only an empty event id to be dropped iOS has no "Unknown" event id placeholder, see the previous commit.
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * feat: add Pigeon native identification contract Replace the MethodChannel tuple with a config-keyed Pigeon get on Android and iOS. Public FpjsProPlugin API is unchanged. * fix: create Android Fingerprint clients atomically ConcurrentHashMap.getOrPut can run the factory twice on first access. computeIfAbsent keeps one client per configuration. * fix: create native Fingerprint clients during init initFpjs only stored Dart config. Native create runs now so location can warm before the first get. Identification still carries full config. * fix: preserve endpoint fallbacks and type cache keys Pigeon kept a single endpoints list, so fallbacks vanished without a primary URL. Cache identity is now a typed key, not a delimiter string. * fix: keep iOS fallbacks when no primary endpoint is set iOS Region only attaches fallbacks on .custom. With no primary URL, use the region's default endpoint as that custom domain so fallbacks still apply. Android already does this with endpoint ?: region.endpointUrl. https://docs.fingerprint.com/docs/ios-sdk * refactor: rename MethodChannelFingerprint to FingerprintNative Document cache keys, R8 error mapping, CompletionBox, and fallbacks without a primary endpoint. * docs: update contributing guidelines with architecture overview and Pigeon usage * docs: label native, Pigeon, and Dart result shapes getVisitorId maps empty visitorId to null without building FingerprintResult. * docs: explain Unknown eventId sentinel and unwrapError aliases * fix: keep JSON null tag values on Android Dart validateTags accepts null. pigeonTagsToNative dropped those entries, so {'campaign': null} reached the SDK as {}. * fix: keep JSON null tags on iOS The v4 iOS SDK already has JSONType.null. The converter dropped nil and NSNull, and nested Flutter maps typed as [AnyHashable: Any]. * fix: keep iOS boolean tags as JSON bools Flutter sends bools as CFBoolean NSNumbers. Casting to Int first turned true into 1. Checking Bool first would also turn integer 0/1 into bools. * feat: swap the public API to Fingerprint and web v4 The static plugin and v3 web agent cannot express the v4 contract. Instance Fingerprint carries config on every get so two clients stay independent. Web uses @fingerprint/agent start/get. * fix: do not pass null into the web agent get JS default parameters apply to undefined only. get(null) throws when the agent reads options.timeout. The Chrome error test now throws a JS object instead of boxing a Future rejection. * fix: keep web cache presets distinct and drop the stale Yarn lock Dart const-canonicalized optimizeCost and aggressive to the same object, so aggressive was sent as optimize-cost. Custom durations also truncated leftover milliseconds to whole seconds. * docs: say native region defaults to US when omitted The constructor comment claimed the agent infers region from the API key. That is only true on web. * docs: document the Fingerprint constructor and get API Reviewers can see the public API in the README diff. PR 6 stays the post-rename migration guide. * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * fix: drop public ready and report create errors from get Web start() is synchronous, so ready could not wait for the bundle. Native create still starts in the constructor. The example status now says the Dart client was created. * fix: reject typed lists in tags Uint8List is a List, so validation accepted it. Pigeon sends typed data, and iOS drops the tag instead of storing a JSON array. * fix: clamp Android timeouts that overflow Int Long.toInt() wraps, so a huge timeout becomes negative and the SDK treats it as already expired. Clamp to Int range, same as RN Double.toInt(). https://docs.fingerprint.com/docs/android-sdk * fix: collapse platform-only error codes into shared ones Web network_connection/network_abort and Android NetworkUnavailableError become network_error. Codes identification never returns fall through to unknown_error. https://docs.fingerprint.com/reference/js-agent-error-handling * test: drop duplicate platform-interface suite Public Fingerprint tests already cover the same mocked create/get forwarding. * refactor: make tag conversion a free function JSONTypeConvertor was only a namespace for static methods. * fix: read inner iOS network error text FPError is not LocalizedError, so localizedDescription is Foundation's generic string. The wrapped URLError's localizedDescription is the user-facing message. * chore: changesets * fix: treat empty endpoints as the regional default Match the JS agent: [] means omit custom endpoints. README now calls web/index.js a loader and says to create the client at startup. * fix: drop empty strings from endpoints An empty first item would become a missing iOS primary and invent the region URL. Filter blanks, then treat an empty list as the default. * chore: tweak readme * fix: type web suspect_score as an optional int JSAny was only needed for sealed_result, which is a string or BinaryOutput. * docs: clarify config equality and omitted web get options * docs: rename the example endpoint env var to ENDPOINTS * chore: rename the example endpoint field to _endpoints * refactor: share one identify helper in the example app * refactor: drop unreachable fallbacks-without-primary path Public endpoints always send a primary when any URL remains, so the native special case and its test never ran for consumers. * fix: stop treating .changeset/AGENTS.md as a changeset Changesets parses every markdown file in that folder except README.md. * chore: drop leftover SCRIPT_URL_PATTERN from e2e scriptUrlPattern is no longer a public option, and the example never reads it. * docs: say custom cache duration is whole seconds The JS agent takes a number of seconds, not a Dart Duration's sub-second remainder. * fix: forward unlisted iOS API error codes Keep unused server codes out of Dart constants. If iOS still sends one, pass the snake_case raw value through for debugging. * feat: rename package to fingerprint_flutter INTER-2400. New pub name so a 5.0.0 alpha can install as fingerprint_flutter. * fix: keep integrationInfo as fingerprint-pro-flutter Server analytics still keys off the old integration name. * docs: point live repo links at fingerprintjs/flutter GitHub will be renamed to flutter when this lands on main. CHANGELOG commit URLs stay; those redirect after the rename. * fix: disable JS agent default endpoint fallbacks * docs: write the 5.0.0 release notes Replace the internal migration plan and split changesets with one consumer migration guide. * docs: print visitorId and eventId in the migration snippet * docs: link the 4.x upgrade note to the changelog * chore: enter pre-release mode * fix: restore SwiftPM product name Flutter converts pub package underscores to hyphens when it generates SwiftPM product dependencies. Keep the target name underscored, but expose fingerprint-flutter so generated iOS projects resolve it. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: clarify SwiftPM product scope The hyphenated product name is required only by Flutters generated native build. Flutter consumers still depend on and import fingerprint_flutter. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: shorten the 5.0.0 changeset * chore: leave max 12 hours validation to JS agent * fix: reject a negative get timeout Dart Duration can be negative. Validate once for native and web, and rename tags.dart to validation.dart. * fix: validate get arguments before create Keep get() non-async so ArgumentError throws now, not as a Future error after create. Native and web just forward. * docs: clarify region defaults and unused Android errors Web infers region from the API key. Remaining Android Error types are not identification errors, so they stay unknown_error. * fix: retry a failed create and wrap all native errors - A failed create was kept forever, so every later get failed the same way. Now the next get retries it. - Native only mapped PlatformException. Other errors (e.g. a missing Flutter binding) escaped get as raw exceptions. They now become unknown_error FingerprintError with the original text as message. * fix: name unmapped Android errors when they have no description Android types without a Dart code are sent as unknown_error. When the SDK gives only its "Unknown" default description, the message was null, so developers saw nothing. Fall back to the type name. * fix: map Pigeon channel errors to unknown_error - channel-error and null-error come from Pigeon, not native SDK code, so they are not documented codes. Send unknown_error and keep the Pigeon text in the message. - Inline unwrapError into its only caller. * refactor: filter empty endpoints only in Dart Fingerprint already drops empty strings and turns an empty list into null. Android, iOS, and the example app no longer repeat it. * test: cover validation through get and web error paths - Tag validation tests now call Fingerprint.get, like a consumer. - Drop result tests that only read back a constructor field. - Web: JS error without a code, and a throw from start. - Native: Pigeon channel error. * docs: clarify cache maximum, config equality, and module overviews - The JS agent checks the 12 hour cache maximum, so the limit can change without a plugin release. - FingerprintConfig equality exists because it keys cached web agents and native clients. - README shows the cache keyPrefix option. * refactor: stop waiting for create in get - The constructor still calls create early, so on Android and iOS the native client, and location collection, start as soon as possible. - get no longer waits for that create. Platform get already creates the client when missing (native getOrCreate, web _agentFor), so waiting only added a round trip and a failed create that could get stuck. - This drops the retry state. A failed early create no longer breaks get, and real create errors still surface from get. * docs: note PlatformException catch no longer works in v5 * docs: list v5 behavior changes in the migration notes - Tags must be JSON-compatible. Invalid tags and a negative timeout throw ArgumentError before identifying. - Custom endpoints still get no automatic fallback, unlike the JS agent v4 default. - Android and iOS still default to the US region. * docs: list the Kotlin Gradle plugin floor in README requirements The plugin's Kotlin code is compiled by the app's Kotlin version, and the native Android SDK is built with Kotlin 2.3 metadata. AGP 8 apps with an older Kotlin Gradle plugin fail to build. The changeset already said this. * fix: throw from the constructor when the Flutter binding is missing (#164) * fix: throw from the constructor when the Flutter binding is missing On Android and iOS the constructor starts the native client right away. Without a binding, that failed inside the stored create future: - the early native create (location warm-up) never ran - every get() rethrew a FlutterError, not a FingerprintError - the client never recovered Read ServicesBinding.instance before anything async, so the standard "call WidgetsFlutterBinding.ensureInitialized()" error is thrown where the client is created. The README now shows that call in main(). https://api.flutter.dev/flutter/widgets/WidgetsFlutterBinding/ensureInitialized.html Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgsmqW19NX6duqtNNc9wzq * chore: tweak readme --------- Co-authored-by: Claude <noreply@anthropic.com> * test: skip NetworkError in JVM error mapping test SDK 4.1.0's obfuscated NetworkError initializer throws IllegalAccessError on the desktop JVM. * fix: name the missing-binding FlutterError ServicesBinding.instance names ServicesBinding, and release builds throw a null-check. The constructor now throws FlutterError that says to call WidgetsFlutterBinding.ensureInitialized(). * fix: run native create and get off the main thread Pigeon ran both host API methods on the platform main thread. On Android, the first client creation loads the SDK classes on the calling thread (~170 ms on an emulator, more on slow devices). Apps create the client at startup, so this blocked the UI thread during launch. - Both methods use one serial background task queue - get must move too: it waits on the client cache lock while create runs, and builds the client itself if create was skipped - A serial queue keeps create before a get sent right after it https://pub.dev/packages/pigeon#task-queue https://docs.flutter.dev/platform-integration/platform-channels#channels-and-platform-threading * docs: explain what the early client start does on each platform The docs said creating the client warms location. That only holds on iOS: - iOS: creating the client starts location collection. The iOS SDK docs recommend creating it at app startup and keeping one instance for the best location precision. - Android: creating the client loads the SDK, so the first get is faster. Location is collected during each get, up to locationTimeout. https://docs.fingerprint.com/docs/ios-sdk#using-location-data-for-proximity-detection https://docs.fingerprint.com/docs/android-sdk * docs: shorten Pigeon host API comments * docs: simplify README client and location notes * refactor!: make fingerprint_flutter.dart the only public library error.dart and result.dart only re-exported src/, and options.dart and region.dart were importable on their own. Apps only need the main library, so move the rest under src/ and export it from there. Dart treats lib/src as private to the package. https://dart.dev/tools/pub/package-layout#implementation-files * refactor!: stop exporting pluginVersion It is an internal value sent as integration info, not something apps should read. Move it to lib/src/plugin_version.dart, and read it directly on each platform instead of passing it through FingerprintConfig, where it never differs between clients. * refactor!: send region to native code as a Pigeon enum Region went to native code as a lowercase string, so Android and iOS each parsed it again and disagreed on bad input: Android threw, iOS fell back to US. A Pigeon enum makes bad input impossible and removes both parsers and the public RegionValue.stringValue extension. Web uses Region.name. Dart now sends US when region is omitted, the documented Android and iOS default. https://pub.dev/packages/pigeon#supported-datatypes * refactor: key native clients on the Pigeon config Pigeon generates value equality for FingerprintNativeConfig (Kotlin equals/hashCode, Swift Hashable), so it can be the cache key directly. This removes the hand-written ClientKey types, which copied every config field and had to be kept in sync. With the key gone, each cache was a map and one lookup, so it moves into FingerprintHostApiImpl. buildConfiguration now runs only when a client is created, not on every get. Configs that only resolve to the same SDK Configuration (no endpoint vs the region's default URL, or iOS with a different Android-only locationTimeoutMillis) now get separate clients. That is harmless. * docs: note the smaller public API in the v5 changeset * refactor!: one error code per client failure on every platform - Client failures use the JS agent code on all platforms: - bad_response_format: Android ResponseCannotBeParsed, iOS jsonParsingError and invalidResponseType - invalid_endpoint: iOS invalidURL. Android reports a malformed endpoint as a network error, so it stays network_error there. - iOS invalidURLParams becomes unknown_error. The plugin builds the integration info, so it is a plugin bug, not an app error. https://docs.fingerprint.com/reference/js-agent-v4-error-handling - Drop constants no platform can send through the plugin: the old client codes above, handle_agent_data, api_key_invalid, wrong_worker_option (typed options rule them out) and bundle_not_defined (loader internal). The strings still pass through if they ever arrive. - iOS forwards APIError.Code.rawValue, which is already the server's snake_case code. This removes the per-code list and the camelCase helper. - Document that on Android and iOS the native SDK turns server codes it does not know into its unknown error and drops the server message. The plugin cannot recover it. Android keeps the eventId. * refactor: keep FingerprintClientCache as its own class Restore the cache as a small class keyed on FingerprintNativeConfig, and move buildConfiguration into it. FingerprintHostApiImpl only maps get calls, and the cache owns turning a config into one SDK client. * feat!: rename pluginVersion to fingerprintFlutterVersion Keep the package version public so apps can log it or add it to support requests. The new name says which package it belongs to. It is exported from the main library. * refactor: keep native client codes consistent with React Native - Keep invalid_url (iOS), json_parsing_error (iOS) and response_cannot_be_parsed (Android). The React Native SDK uses the same codes, so both SDKs stay alike. - iOS invalidURLParams and invalidResponseType go to unknown_error with their message. Both are practically unreachable. - Web constants are now the codes on the public JS agent error page, minus codes for options the plugin does not expose. Drops server_error and worker_initialization_failed, which that page does not list. https://docs.fingerprint.com/reference/js-agent-v4-error-handling - Fix comments that did not match the code and trim repeated ones. * docs: explain why iOS forwards rawValue and how unlisted codes differ - iOS SDK decodes the server code into APIError.Code, so rawValue is the exact server string. That is why the plugin needs no per-code mapping. - Dartdoc now says iOS and web pass unlisted server codes through, while Android sends unknown_error with the message (no code string to forward). * fix: keep iOS invalid_url_params and invalid_response_type codes The iOS SDK's public FPError declares both cases. Whether they are thrown today is not a public contract, so map them to their own codes, the same as the React Native SDK, instead of folding them into unknown_error. * fix: keep server_error and worker_initialization_failed constants Both are in the JS agent's public ErrorCode type. Missing from the error docs page is not a promise they are never thrown, so keep them. Only drop codes the plugin itself rules out or the type marks as loader-internal. https://docs.fingerprint.com/reference/js-agent-v4-error-handling * chore: shorten readme * refactor: shorten error comments and fold UnknownError into else - Android UnknownError and Server API only types both became unknown_error, so one branch covers both. An UnknownError without a description now gets its type name as message, like the others. - Trim the error comments and changeset line. * docs: drop the error pass-through sentence from the changeset It covered edge cases that do not affect migration. The FingerprintError Dartdoc documents them. * chore: agents.md * test: point merged iOS tests at fingerprint_flutter - v5 added the iOS unit tests before the package rename. Update the test imports and the CI working directory to the new package name. - Rename FPJSErrorFlutterTests to FPErrorFlutterTests to match FPError+Flutter.swift. * test: iOS forwards the server's error code string as is Decode APIError from literal server JSON, not from APIError.Code.rawValue, so the test fails if the plugin ever sends the Swift case name or any other transform instead of the server's code. * refactor: map errors to FingerprintError only in get The Fingerprint constructor ignores the create future, so mapping its errors was dead code. get already wraps every failure, including a failed web agent start, so the web code needs one catch, not two. Also fix the FingerprintConfig equality note: only the web agent cache uses Dart equality. Native code keys clients on FingerprintNativeConfig. * refactor: build the Fingerprint config once The public fields become getters over one final FingerprintConfig, so endpoints are normalized in the initializer and _config no longer needs late. * chore: tidy the Android plugin and pubspec - FingerprintPlugin.hostApiImpl was never read. Pigeon keeps the handler alive until setUp(null). - @nonnull has no effect on Kotlin parameters. - Dart rejects negative timeouts, so the Int clamp only needs an upper bound. - Drop the plugin template comments from pubspec.yaml. meta stays: the generated Pigeon Dart code imports it. * refactor(ios): inline the event id check The "Unknown" placeholder is an Android SDK default. iOS APIError.eventId is a non-optional String decoded from the server response, so only an empty id needs to become nil. https://docs.fingerprint.com/docs/ios-sdk * test: drop duplicate web start failure test get covers start failures in 'maps a throw from start and retries', and plain-value throws in 'maps a JS value without a code'. Also say the create doc means the returned future: a missing Flutter binding still throws from the Fingerprint constructor. * test(ios): expect only an empty event id to be dropped iOS has no "Unknown" event id placeholder, see the previous commit. * refactor(android): use the SDK default location timeout Configuration.DEFAULT_LOCATION_TIMEOUT_MILLIS is public, so the plugin follows the SDK default instead of repeating 5000. * fix(android): complete get with an error if client creation throws Pigeon catches throws only in sync handlers like create. In async get a throw would escape the handler instead of completing the Dart Future. Pigeon would also send a plain exception with its class name as code and a stack trace in details, which Dart reads as eventId. Send unknown_error instead. * test(android): test Configuration mapping through FingerprintClientCache The cache builds the Configuration, so its tests cover the mapping. * chore: use iOS native SDK 4.1.x - Package.swift range is now upToNextMinor(from: "4.1.0"). The podspec reads it, so CocoaPods gets Fingerprint-iOS >= 4.1.0, < 4.2.0. - Build Metadata with the `tags:` init added in 4.1.0 instead of calling setTag per key. https://docs.fingerprint.com/docs/changelog-ios-sdk#v4-1-0 --------- Co-authored-by: Claude <noreply@anthropic.com>
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * feat: add Pigeon native identification contract Replace the MethodChannel tuple with a config-keyed Pigeon get on Android and iOS. Public FpjsProPlugin API is unchanged. * fix: create Android Fingerprint clients atomically ConcurrentHashMap.getOrPut can run the factory twice on first access. computeIfAbsent keeps one client per configuration. * fix: create native Fingerprint clients during init initFpjs only stored Dart config. Native create runs now so location can warm before the first get. Identification still carries full config. * fix: preserve endpoint fallbacks and type cache keys Pigeon kept a single endpoints list, so fallbacks vanished without a primary URL. Cache identity is now a typed key, not a delimiter string. * fix: keep iOS fallbacks when no primary endpoint is set iOS Region only attaches fallbacks on .custom. With no primary URL, use the region's default endpoint as that custom domain so fallbacks still apply. Android already does this with endpoint ?: region.endpointUrl. https://docs.fingerprint.com/docs/ios-sdk * refactor: rename MethodChannelFingerprint to FingerprintNative Document cache keys, R8 error mapping, CompletionBox, and fallbacks without a primary endpoint. * docs: update contributing guidelines with architecture overview and Pigeon usage * docs: label native, Pigeon, and Dart result shapes getVisitorId maps empty visitorId to null without building FingerprintResult. * docs: explain Unknown eventId sentinel and unwrapError aliases * fix: keep JSON null tag values on Android Dart validateTags accepts null. pigeonTagsToNative dropped those entries, so {'campaign': null} reached the SDK as {}. * fix: keep JSON null tags on iOS The v4 iOS SDK already has JSONType.null. The converter dropped nil and NSNull, and nested Flutter maps typed as [AnyHashable: Any]. * fix: keep iOS boolean tags as JSON bools Flutter sends bools as CFBoolean NSNumbers. Casting to Int first turned true into 1. Checking Bool first would also turn integer 0/1 into bools. * feat: swap the public API to Fingerprint and web v4 The static plugin and v3 web agent cannot express the v4 contract. Instance Fingerprint carries config on every get so two clients stay independent. Web uses @fingerprint/agent start/get. * fix: do not pass null into the web agent get JS default parameters apply to undefined only. get(null) throws when the agent reads options.timeout. The Chrome error test now throws a JS object instead of boxing a Future rejection. * fix: keep web cache presets distinct and drop the stale Yarn lock Dart const-canonicalized optimizeCost and aggressive to the same object, so aggressive was sent as optimize-cost. Custom durations also truncated leftover milliseconds to whole seconds. * docs: say native region defaults to US when omitted The constructor comment claimed the agent infers region from the API key. That is only true on web. * docs: document the Fingerprint constructor and get API Reviewers can see the public API in the README diff. PR 6 stays the post-rename migration guide. * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * fix: drop public ready and report create errors from get Web start() is synchronous, so ready could not wait for the bundle. Native create still starts in the constructor. The example status now says the Dart client was created. * fix: reject typed lists in tags Uint8List is a List, so validation accepted it. Pigeon sends typed data, and iOS drops the tag instead of storing a JSON array. * fix: clamp Android timeouts that overflow Int Long.toInt() wraps, so a huge timeout becomes negative and the SDK treats it as already expired. Clamp to Int range, same as RN Double.toInt(). https://docs.fingerprint.com/docs/android-sdk * fix: collapse platform-only error codes into shared ones Web network_connection/network_abort and Android NetworkUnavailableError become network_error. Codes identification never returns fall through to unknown_error. https://docs.fingerprint.com/reference/js-agent-error-handling * test: drop duplicate platform-interface suite Public Fingerprint tests already cover the same mocked create/get forwarding. * refactor: make tag conversion a free function JSONTypeConvertor was only a namespace for static methods. * fix: read inner iOS network error text FPError is not LocalizedError, so localizedDescription is Foundation's generic string. The wrapped URLError's localizedDescription is the user-facing message. * chore: changesets * fix: treat empty endpoints as the regional default Match the JS agent: [] means omit custom endpoints. README now calls web/index.js a loader and says to create the client at startup. * fix: drop empty strings from endpoints An empty first item would become a missing iOS primary and invent the region URL. Filter blanks, then treat an empty list as the default. * chore: tweak readme * fix: type web suspect_score as an optional int JSAny was only needed for sealed_result, which is a string or BinaryOutput. * docs: clarify config equality and omitted web get options * docs: rename the example endpoint env var to ENDPOINTS * chore: rename the example endpoint field to _endpoints * refactor: share one identify helper in the example app * refactor: drop unreachable fallbacks-without-primary path Public endpoints always send a primary when any URL remains, so the native special case and its test never ran for consumers. * fix: stop treating .changeset/AGENTS.md as a changeset Changesets parses every markdown file in that folder except README.md. * chore: drop leftover SCRIPT_URL_PATTERN from e2e scriptUrlPattern is no longer a public option, and the example never reads it. * docs: say custom cache duration is whole seconds The JS agent takes a number of seconds, not a Dart Duration's sub-second remainder. * fix: forward unlisted iOS API error codes Keep unused server codes out of Dart constants. If iOS still sends one, pass the snake_case raw value through for debugging. * feat: rename package to fingerprint_flutter INTER-2400. New pub name so a 5.0.0 alpha can install as fingerprint_flutter. * fix: keep integrationInfo as fingerprint-pro-flutter Server analytics still keys off the old integration name. * docs: point live repo links at fingerprintjs/flutter GitHub will be renamed to flutter when this lands on main. CHANGELOG commit URLs stay; those redirect after the rename. * fix: disable JS agent default endpoint fallbacks * docs: write the 5.0.0 release notes Replace the internal migration plan and split changesets with one consumer migration guide. * docs: print visitorId and eventId in the migration snippet * docs: link the 4.x upgrade note to the changelog * chore: enter pre-release mode * fix: restore SwiftPM product name Flutter converts pub package underscores to hyphens when it generates SwiftPM product dependencies. Keep the target name underscored, but expose fingerprint-flutter so generated iOS projects resolve it. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: clarify SwiftPM product scope The hyphenated product name is required only by Flutters generated native build. Flutter consumers still depend on and import fingerprint_flutter. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: shorten the 5.0.0 changeset * chore: leave max 12 hours validation to JS agent * fix: reject a negative get timeout Dart Duration can be negative. Validate once for native and web, and rename tags.dart to validation.dart. * fix: validate get arguments before create Keep get() non-async so ArgumentError throws now, not as a Future error after create. Native and web just forward. * docs: clarify region defaults and unused Android errors Web infers region from the API key. Remaining Android Error types are not identification errors, so they stay unknown_error. * fix: retry a failed create and wrap all native errors - A failed create was kept forever, so every later get failed the same way. Now the next get retries it. - Native only mapped PlatformException. Other errors (e.g. a missing Flutter binding) escaped get as raw exceptions. They now become unknown_error FingerprintError with the original text as message. * fix: name unmapped Android errors when they have no description Android types without a Dart code are sent as unknown_error. When the SDK gives only its "Unknown" default description, the message was null, so developers saw nothing. Fall back to the type name. * fix: map Pigeon channel errors to unknown_error - channel-error and null-error come from Pigeon, not native SDK code, so they are not documented codes. Send unknown_error and keep the Pigeon text in the message. - Inline unwrapError into its only caller. * refactor: filter empty endpoints only in Dart Fingerprint already drops empty strings and turns an empty list into null. Android, iOS, and the example app no longer repeat it. * test: cover validation through get and web error paths - Tag validation tests now call Fingerprint.get, like a consumer. - Drop result tests that only read back a constructor field. - Web: JS error without a code, and a throw from start. - Native: Pigeon channel error. * docs: clarify cache maximum, config equality, and module overviews - The JS agent checks the 12 hour cache maximum, so the limit can change without a plugin release. - FingerprintConfig equality exists because it keys cached web agents and native clients. - README shows the cache keyPrefix option. * refactor: stop waiting for create in get - The constructor still calls create early, so on Android and iOS the native client, and location collection, start as soon as possible. - get no longer waits for that create. Platform get already creates the client when missing (native getOrCreate, web _agentFor), so waiting only added a round trip and a failed create that could get stuck. - This drops the retry state. A failed early create no longer breaks get, and real create errors still surface from get. * docs: note PlatformException catch no longer works in v5 * docs: list v5 behavior changes in the migration notes - Tags must be JSON-compatible. Invalid tags and a negative timeout throw ArgumentError before identifying. - Custom endpoints still get no automatic fallback, unlike the JS agent v4 default. - Android and iOS still default to the US region. * docs: list the Kotlin Gradle plugin floor in README requirements The plugin's Kotlin code is compiled by the app's Kotlin version, and the native Android SDK is built with Kotlin 2.3 metadata. AGP 8 apps with an older Kotlin Gradle plugin fail to build. The changeset already said this. * fix: throw from the constructor when the Flutter binding is missing (#164) * fix: throw from the constructor when the Flutter binding is missing On Android and iOS the constructor starts the native client right away. Without a binding, that failed inside the stored create future: - the early native create (location warm-up) never ran - every get() rethrew a FlutterError, not a FingerprintError - the client never recovered Read ServicesBinding.instance before anything async, so the standard "call WidgetsFlutterBinding.ensureInitialized()" error is thrown where the client is created. The README now shows that call in main(). https://api.flutter.dev/flutter/widgets/WidgetsFlutterBinding/ensureInitialized.html Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgsmqW19NX6duqtNNc9wzq * chore: tweak readme --------- Co-authored-by: Claude <noreply@anthropic.com> * test: skip NetworkError in JVM error mapping test SDK 4.1.0's obfuscated NetworkError initializer throws IllegalAccessError on the desktop JVM. * fix: name the missing-binding FlutterError ServicesBinding.instance names ServicesBinding, and release builds throw a null-check. The constructor now throws FlutterError that says to call WidgetsFlutterBinding.ensureInitialized(). * fix: run native create and get off the main thread Pigeon ran both host API methods on the platform main thread. On Android, the first client creation loads the SDK classes on the calling thread (~170 ms on an emulator, more on slow devices). Apps create the client at startup, so this blocked the UI thread during launch. - Both methods use one serial background task queue - get must move too: it waits on the client cache lock while create runs, and builds the client itself if create was skipped - A serial queue keeps create before a get sent right after it https://pub.dev/packages/pigeon#task-queue https://docs.flutter.dev/platform-integration/platform-channels#channels-and-platform-threading * docs: explain what the early client start does on each platform The docs said creating the client warms location. That only holds on iOS: - iOS: creating the client starts location collection. The iOS SDK docs recommend creating it at app startup and keeping one instance for the best location precision. - Android: creating the client loads the SDK, so the first get is faster. Location is collected during each get, up to locationTimeout. https://docs.fingerprint.com/docs/ios-sdk#using-location-data-for-proximity-detection https://docs.fingerprint.com/docs/android-sdk * docs: shorten Pigeon host API comments * docs: simplify README client and location notes * refactor!: make fingerprint_flutter.dart the only public library error.dart and result.dart only re-exported src/, and options.dart and region.dart were importable on their own. Apps only need the main library, so move the rest under src/ and export it from there. Dart treats lib/src as private to the package. https://dart.dev/tools/pub/package-layout#implementation-files * refactor!: stop exporting pluginVersion It is an internal value sent as integration info, not something apps should read. Move it to lib/src/plugin_version.dart, and read it directly on each platform instead of passing it through FingerprintConfig, where it never differs between clients. * refactor!: send region to native code as a Pigeon enum Region went to native code as a lowercase string, so Android and iOS each parsed it again and disagreed on bad input: Android threw, iOS fell back to US. A Pigeon enum makes bad input impossible and removes both parsers and the public RegionValue.stringValue extension. Web uses Region.name. Dart now sends US when region is omitted, the documented Android and iOS default. https://pub.dev/packages/pigeon#supported-datatypes * refactor: key native clients on the Pigeon config Pigeon generates value equality for FingerprintNativeConfig (Kotlin equals/hashCode, Swift Hashable), so it can be the cache key directly. This removes the hand-written ClientKey types, which copied every config field and had to be kept in sync. With the key gone, each cache was a map and one lookup, so it moves into FingerprintHostApiImpl. buildConfiguration now runs only when a client is created, not on every get. Configs that only resolve to the same SDK Configuration (no endpoint vs the region's default URL, or iOS with a different Android-only locationTimeoutMillis) now get separate clients. That is harmless. * docs: note the smaller public API in the v5 changeset * refactor!: one error code per client failure on every platform - Client failures use the JS agent code on all platforms: - bad_response_format: Android ResponseCannotBeParsed, iOS jsonParsingError and invalidResponseType - invalid_endpoint: iOS invalidURL. Android reports a malformed endpoint as a network error, so it stays network_error there. - iOS invalidURLParams becomes unknown_error. The plugin builds the integration info, so it is a plugin bug, not an app error. https://docs.fingerprint.com/reference/js-agent-v4-error-handling - Drop constants no platform can send through the plugin: the old client codes above, handle_agent_data, api_key_invalid, wrong_worker_option (typed options rule them out) and bundle_not_defined (loader internal). The strings still pass through if they ever arrive. - iOS forwards APIError.Code.rawValue, which is already the server's snake_case code. This removes the per-code list and the camelCase helper. - Document that on Android and iOS the native SDK turns server codes it does not know into its unknown error and drops the server message. The plugin cannot recover it. Android keeps the eventId. * refactor: keep FingerprintClientCache as its own class Restore the cache as a small class keyed on FingerprintNativeConfig, and move buildConfiguration into it. FingerprintHostApiImpl only maps get calls, and the cache owns turning a config into one SDK client. * feat!: rename pluginVersion to fingerprintFlutterVersion Keep the package version public so apps can log it or add it to support requests. The new name says which package it belongs to. It is exported from the main library. * refactor: keep native client codes consistent with React Native - Keep invalid_url (iOS), json_parsing_error (iOS) and response_cannot_be_parsed (Android). The React Native SDK uses the same codes, so both SDKs stay alike. - iOS invalidURLParams and invalidResponseType go to unknown_error with their message. Both are practically unreachable. - Web constants are now the codes on the public JS agent error page, minus codes for options the plugin does not expose. Drops server_error and worker_initialization_failed, which that page does not list. https://docs.fingerprint.com/reference/js-agent-v4-error-handling - Fix comments that did not match the code and trim repeated ones. * docs: explain why iOS forwards rawValue and how unlisted codes differ - iOS SDK decodes the server code into APIError.Code, so rawValue is the exact server string. That is why the plugin needs no per-code mapping. - Dartdoc now says iOS and web pass unlisted server codes through, while Android sends unknown_error with the message (no code string to forward). * fix: keep iOS invalid_url_params and invalid_response_type codes The iOS SDK's public FPError declares both cases. Whether they are thrown today is not a public contract, so map them to their own codes, the same as the React Native SDK, instead of folding them into unknown_error. * fix: keep server_error and worker_initialization_failed constants Both are in the JS agent's public ErrorCode type. Missing from the error docs page is not a promise they are never thrown, so keep them. Only drop codes the plugin itself rules out or the type marks as loader-internal. https://docs.fingerprint.com/reference/js-agent-v4-error-handling * chore: shorten readme * refactor: shorten error comments and fold UnknownError into else - Android UnknownError and Server API only types both became unknown_error, so one branch covers both. An UnknownError without a description now gets its type name as message, like the others. - Trim the error comments and changeset line. * docs: drop the error pass-through sentence from the changeset It covered edge cases that do not affect migration. The FingerprintError Dartdoc documents them. * chore: agents.md * test: point merged iOS tests at fingerprint_flutter - v5 added the iOS unit tests before the package rename. Update the test imports and the CI working directory to the new package name. - Rename FPJSErrorFlutterTests to FPErrorFlutterTests to match FPError+Flutter.swift. * test: iOS forwards the server's error code string as is Decode APIError from literal server JSON, not from APIError.Code.rawValue, so the test fails if the plugin ever sends the Swift case name or any other transform instead of the server's code. * refactor: map errors to FingerprintError only in get The Fingerprint constructor ignores the create future, so mapping its errors was dead code. get already wraps every failure, including a failed web agent start, so the web code needs one catch, not two. Also fix the FingerprintConfig equality note: only the web agent cache uses Dart equality. Native code keys clients on FingerprintNativeConfig. * refactor: build the Fingerprint config once The public fields become getters over one final FingerprintConfig, so endpoints are normalized in the initializer and _config no longer needs late. * chore: tidy the Android plugin and pubspec - FingerprintPlugin.hostApiImpl was never read. Pigeon keeps the handler alive until setUp(null). - @nonnull has no effect on Kotlin parameters. - Dart rejects negative timeouts, so the Int clamp only needs an upper bound. - Drop the plugin template comments from pubspec.yaml. meta stays: the generated Pigeon Dart code imports it. * refactor(ios): inline the event id check The "Unknown" placeholder is an Android SDK default. iOS APIError.eventId is a non-optional String decoded from the server response, so only an empty id needs to become nil. https://docs.fingerprint.com/docs/ios-sdk * test: drop duplicate web start failure test get covers start failures in 'maps a throw from start and retries', and plain-value throws in 'maps a JS value without a code'. Also say the create doc means the returned future: a missing Flutter binding still throws from the Fingerprint constructor. * test(ios): expect only an empty event id to be dropped iOS has no "Unknown" event id placeholder, see the previous commit. * refactor(android): use the SDK default location timeout Configuration.DEFAULT_LOCATION_TIMEOUT_MILLIS is public, so the plugin follows the SDK default instead of repeating 5000. * fix(android): complete get with an error if client creation throws Pigeon catches throws only in sync handlers like create. In async get a throw would escape the handler instead of completing the Dart Future. Pigeon would also send a plain exception with its class name as code and a stack trace in details, which Dart reads as eventId. Send unknown_error instead. * test(android): test Configuration mapping through FingerprintClientCache The cache builds the Configuration, so its tests cover the mapping. * refactor: turn empty ids into null only in Dart FingerprintError and FingerprintResult constructors already turn empty strings into null on every platform. Native code only drops what its SDK invents, the Android "Unknown" placeholder. * refactor: drop plugin_platform_interface Its token check stops other packages from implementing a public platform interface with `implements`. FingerprintPlatform is private to this package, so there is nothing to guard. https://pub.dev/packages/plugin_platform_interface * docs: keep platform details out of public result and error docs Users need what can be null and which codes exist. How each platform sends missing values is an internal note. * feat: reject a negative Android location timeout get already rejects a negative timeout. The Android location timeout reached the SDK as a negative millisecond value instead. The constructor now throws ArgumentError before starting the client. --------- Co-authored-by: Claude <noreply@anthropic.com>
* refactor: add a platform interface Add `plugin_platform_interface` with `FingerprintPlatform`, `MethodChannelFingerprint`, and `FingerprintWeb`. `FpjsProPlugin` keeps its public API and delegates to `FingerprintPlatform.instance`. The web implementation now implements the interface directly instead of answering the plugin's own method channel in the same process. Pigeon bindings will live inside `MethodChannelFingerprint`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * fix: keep web sealed result and async errors as before The web round trip normalized a missing sealed result to an empty string before the core parsed it, so do it in the JS object conversion, matching the native path. Keep `getVisitorId` async, so an uninitialized client completes the future with the error instead of throwing synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * test: cover the web implementation, keep FpjsProPlugin.channelName Removing the same-process channel hop changed two web contracts, which the suite could not see: it fakes the platform or mocks the native channel. Add browser tests for the sealed-result normalization and for errors arriving through the future, and run them in CI with `--platform chrome`. Restore `FpjsProPlugin.channelName` as a forwarding const, so consumers mocking the channel keep working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: correct the changeset for the restored channel name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * docs: say why a missing sealed result is an empty string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * chore: drop the changeset for an internal refactor Nothing here reaches a consumer: the public API and its behavior are unchanged, and `FpjsProPluginWeb` is only called by the generated web registrant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zoBUA7kbtcZskLGLsTSD * refactor: keep the platform interface out of the public API Move FingerprintPlatform, MethodChannelFingerprint, and FingerprintWeb into lib/src so they are not part of the published API. Pass pluginVersion through FingerprintConfig so the implementations no longer import fpjs_pro_plugin.dart. Keep FpjsProPlugin.channelName as the literal consumers already use. * feat: add the v4 identification result `FingerprintResult` replaces `FingerprintJSProResponse` in PR 4c. Nothing imports it yet. The constructor normalizes what the platforms report differently, so no caller has to: a missing `visitorId`, which only Zero Trust omits, becomes an empty string, and an empty `sealedResult`, which Android and iOS send where the web agent omits the field, becomes null. `suspectScore` stays nullable. React Native carries a `-1` sentinel only because its codegen cannot express a nullable number; Pigeon can, so `0` keeps meaning zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: add the v4 error matrix `FingerprintErrorCode` is the matrix the migration plan wants reviewed before Pigeon bindings are generated, and `FingerprintError` is the exception 4c throws. Nothing imports either yet. Codes are the API's own snake_case error keys, read from the v4 sources: the `Error` subclasses in `com.fingerprint.android` and the server keys its factory maps to them, the `FPError` cases and `APIError.Code` raw values in the iOS module interface, and the web agent's `ERROR_CODE_*` constants. One code therefore covers all three platforms even where the native types are named differently, and `rawCode` keeps the string verbatim so an unlisted code costs nothing. Two conditions that React Native labels twice are unified here, because its Android and iOS arms disagree: the Android class `RequestTimeout` comes from `request_read_timeout`, and `RequestNotFound` from `event_not_found`. The Kotlin in 4b has to key off the server condition rather than the class name for those two. The matrix also keeps six codes React Native's union omits but its bridges can still emit. Its union is an open string type; a closed enum that dropped them would make `unknown` mean two things. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * feat: validate tags and shape them per platform Nothing imports these yet either. `validateTags` accepts anything recursively JSON-compatible and rejects what cannot reach the server: a Dart object with no JSON form, a non-string map key, and a non-finite number. The `ArgumentError` names the path, such as `tags['items'][1]`. React Native validates nothing and silently drops what it cannot convert, which is worth failing loudly on. `tagsForNative` wraps anything that is not a root map as `{'tag': value}`, since both native SDKs need a map, and `tagsForWeb` forwards unwrapped. That asymmetry matches React Native, and is what reaches the backend for its users today. Non-finite numbers are checked in one `num` branch rather than separate `int` and `double` ones. On the web every number is a double and `double.infinity is int` is true, because the check is a floor comparison, so split branches let infinity through. The browser test run caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: reject tags that contain themselves A self-referential list or map walked forever and died on a stack overflow rather than the documented `ArgumentError`. Reported by Copilot on #156, and reproduced: the unfixed validator spins for two minutes before the stack runs out. `_validate` now carries the collections it is inside, outermost first, and rejects a back-edge by identity. Identity rather than equality because shared structure is not a cycle: the same map twice side by side serializes to two copies, so it stays valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep rawCode verbatim and reclassify sandboxed_iframe Both reported by Copilot on #156. `rawCode` was documented as the code exactly as the platform reported it, then rewrote an empty one to `unknown_error`, which made a platform that reported no code indistinguishable from one that reported `unknown_error`. Having both `code` and `rawCode` is what lets us avoid that trade: `code` reports `unknown` either way, and `rawCode` stays as given. `sandboxed_iframe` moves to the web client section. It sits in the agent's `ApiErrorCode` type union, which is why it was grouped as a server error, but the agent raises it locally with its own canned message, "Running inside sandboxed iframes is not supported", declared alongside `csp_block` and `client_timeout` in the client message map. No request is made, so there is no event id to forward, and the section heading is what tells 4b that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: keep the codeless constructor codeless Reported by Copilot on #156, and the same guarantee 84f0857 added: the main constructor keeps `rawCode` verbatim, but `FingerprintError.unknown` still filled in `unknown_error`, so a web rejection that carried no code was indistinguishable from a platform that actually reported `unknown_error`. It now leaves `rawCode` empty, and `code` is `unknown` either way. `toString` prints `no code` for an empty raw code, rather than leaving an empty slot mid-line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * docs: name the one client error that carries an event id Two doc comments stated the event-id rule as an absolute and so contradicted `responseCannotBeParsed`, which the matrix classifies as a client error and documents as able to carry an id: the request reached the server, and only reading the reply failed. Both now name the exception. Worth being exact about rather than leaving to the member comment: this rule is what 4b reads to decide whether to forward an id, so a consumer following the field doc would discard a valid one. Reported by Copilot on #156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSYHbmBnFVyPuvgc1vwoPK * fix: address platform interface review * refactor: update testing practices and enhance AGENTS.md guidelines * refactor: keep tags map-shaped Use one map-shaped tags contract on every platform. * refactor: use string error codes with known constants Drop the FingerprintErrorCode enum in favor of open string codes and class constants; align migration plan and tests. * test: drop tautological result and error constructor tests Keep the cases that pin normalization and open codes. * docs: require public sources for design-note links * fix: drop event_not_found from identification error constants Identification cannot return that Server API code. Unfamiliar strings still pass through. * docs: shorten dart foundation comments * docs: record 4b native follow-ups in the migration plan Move the v4 source findings out of the PR body: R8, event id forwarding, iOS error text, nil iOS codes, and the web package swap. * fix: make FingerprintResult.visitorId nullable Native empty string and JS omit both become null, matching the JS agent. * chore: comment * test: drop tautological FingerprintError event-id test * docs: fix Copilot review nits * feat: add Pigeon native identification contract Replace the MethodChannel tuple with a config-keyed Pigeon get on Android and iOS. Public FpjsProPlugin API is unchanged. * fix: create Android Fingerprint clients atomically ConcurrentHashMap.getOrPut can run the factory twice on first access. computeIfAbsent keeps one client per configuration. * fix: create native Fingerprint clients during init initFpjs only stored Dart config. Native create runs now so location can warm before the first get. Identification still carries full config. * fix: preserve endpoint fallbacks and type cache keys Pigeon kept a single endpoints list, so fallbacks vanished without a primary URL. Cache identity is now a typed key, not a delimiter string. * fix: keep iOS fallbacks when no primary endpoint is set iOS Region only attaches fallbacks on .custom. With no primary URL, use the region's default endpoint as that custom domain so fallbacks still apply. Android already does this with endpoint ?: region.endpointUrl. https://docs.fingerprint.com/docs/ios-sdk * refactor: rename MethodChannelFingerprint to FingerprintNative Document cache keys, R8 error mapping, CompletionBox, and fallbacks without a primary endpoint. * docs: update contributing guidelines with architecture overview and Pigeon usage * docs: label native, Pigeon, and Dart result shapes getVisitorId maps empty visitorId to null without building FingerprintResult. * docs: explain Unknown eventId sentinel and unwrapError aliases * fix: keep JSON null tag values on Android Dart validateTags accepts null. pigeonTagsToNative dropped those entries, so {'campaign': null} reached the SDK as {}. * fix: keep JSON null tags on iOS The v4 iOS SDK already has JSONType.null. The converter dropped nil and NSNull, and nested Flutter maps typed as [AnyHashable: Any]. * fix: keep iOS boolean tags as JSON bools Flutter sends bools as CFBoolean NSNumbers. Casting to Int first turned true into 1. Checking Bool first would also turn integer 0/1 into bools. * feat: swap the public API to Fingerprint and web v4 The static plugin and v3 web agent cannot express the v4 contract. Instance Fingerprint carries config on every get so two clients stay independent. Web uses @fingerprint/agent start/get. * fix: do not pass null into the web agent get JS default parameters apply to undefined only. get(null) throws when the agent reads options.timeout. The Chrome error test now throws a JS object instead of boxing a Future rejection. * fix: keep web cache presets distinct and drop the stale Yarn lock Dart const-canonicalized optimizeCost and aggressive to the same object, so aggressive was sent as optimize-cost. Custom durations also truncated leftover milliseconds to whole seconds. * docs: say native region defaults to US when omitted The constructor comment claimed the agent infers region from the API key. That is only true on web. * docs: document the Fingerprint constructor and get API Reviewers can see the public API in the README diff. PR 6 stays the post-rename migration guide. * refactor: simplify tag validation Keep JSON types, string keys, and finite numbers. Drop cycle tracking; a self-referential map is not a real tags payload. * fix: drop public ready and report create errors from get Web start() is synchronous, so ready could not wait for the bundle. Native create still starts in the constructor. The example status now says the Dart client was created. * fix: reject typed lists in tags Uint8List is a List, so validation accepted it. Pigeon sends typed data, and iOS drops the tag instead of storing a JSON array. * fix: clamp Android timeouts that overflow Int Long.toInt() wraps, so a huge timeout becomes negative and the SDK treats it as already expired. Clamp to Int range, same as RN Double.toInt(). https://docs.fingerprint.com/docs/android-sdk * fix: collapse platform-only error codes into shared ones Web network_connection/network_abort and Android NetworkUnavailableError become network_error. Codes identification never returns fall through to unknown_error. https://docs.fingerprint.com/reference/js-agent-error-handling * test: drop duplicate platform-interface suite Public Fingerprint tests already cover the same mocked create/get forwarding. * refactor: make tag conversion a free function JSONTypeConvertor was only a namespace for static methods. * fix: read inner iOS network error text FPError is not LocalizedError, so localizedDescription is Foundation's generic string. The wrapped URLError's localizedDescription is the user-facing message. * chore: changesets * fix: treat empty endpoints as the regional default Match the JS agent: [] means omit custom endpoints. README now calls web/index.js a loader and says to create the client at startup. * fix: drop empty strings from endpoints An empty first item would become a missing iOS primary and invent the region URL. Filter blanks, then treat an empty list as the default. * chore: tweak readme * fix: type web suspect_score as an optional int JSAny was only needed for sealed_result, which is a string or BinaryOutput. * docs: clarify config equality and omitted web get options * docs: rename the example endpoint env var to ENDPOINTS * chore: rename the example endpoint field to _endpoints * refactor: share one identify helper in the example app * refactor: drop unreachable fallbacks-without-primary path Public endpoints always send a primary when any URL remains, so the native special case and its test never ran for consumers. * fix: stop treating .changeset/AGENTS.md as a changeset Changesets parses every markdown file in that folder except README.md. * chore: drop leftover SCRIPT_URL_PATTERN from e2e scriptUrlPattern is no longer a public option, and the example never reads it. * docs: say custom cache duration is whole seconds The JS agent takes a number of seconds, not a Dart Duration's sub-second remainder. * fix: forward unlisted iOS API error codes Keep unused server codes out of Dart constants. If iOS still sends one, pass the snake_case raw value through for debugging. * feat: rename package to fingerprint_flutter INTER-2400. New pub name so a 5.0.0 alpha can install as fingerprint_flutter. * fix: keep integrationInfo as fingerprint-pro-flutter Server analytics still keys off the old integration name. * docs: point live repo links at fingerprintjs/flutter GitHub will be renamed to flutter when this lands on main. CHANGELOG commit URLs stay; those redirect after the rename. * fix: disable JS agent default endpoint fallbacks * docs: write the 5.0.0 release notes Replace the internal migration plan and split changesets with one consumer migration guide. * docs: print visitorId and eventId in the migration snippet * docs: link the 4.x upgrade note to the changelog * chore: enter pre-release mode * fix: restore SwiftPM product name Flutter converts pub package underscores to hyphens when it generates SwiftPM product dependencies. Keep the target name underscored, but expose fingerprint-flutter so generated iOS projects resolve it. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: clarify SwiftPM product scope The hyphenated product name is required only by Flutters generated native build. Flutter consumers still depend on and import fingerprint_flutter. https://github.com/flutter/flutter/blob/3.47.5/packages/flutter_tools/lib/src/commands/build_swift_package.dart#L1143-L1147 * docs: shorten the 5.0.0 changeset * chore: leave max 12 hours validation to JS agent * fix: reject a negative get timeout Dart Duration can be negative. Validate once for native and web, and rename tags.dart to validation.dart. * fix: validate get arguments before create Keep get() non-async so ArgumentError throws now, not as a Future error after create. Native and web just forward. * docs: clarify region defaults and unused Android errors Web infers region from the API key. Remaining Android Error types are not identification errors, so they stay unknown_error. * fix: retry a failed create and wrap all native errors - A failed create was kept forever, so every later get failed the same way. Now the next get retries it. - Native only mapped PlatformException. Other errors (e.g. a missing Flutter binding) escaped get as raw exceptions. They now become unknown_error FingerprintError with the original text as message. * fix: name unmapped Android errors when they have no description Android types without a Dart code are sent as unknown_error. When the SDK gives only its "Unknown" default description, the message was null, so developers saw nothing. Fall back to the type name. * fix: map Pigeon channel errors to unknown_error - channel-error and null-error come from Pigeon, not native SDK code, so they are not documented codes. Send unknown_error and keep the Pigeon text in the message. - Inline unwrapError into its only caller. * refactor: filter empty endpoints only in Dart Fingerprint already drops empty strings and turns an empty list into null. Android, iOS, and the example app no longer repeat it. * test: cover validation through get and web error paths - Tag validation tests now call Fingerprint.get, like a consumer. - Drop result tests that only read back a constructor field. - Web: JS error without a code, and a throw from start. - Native: Pigeon channel error. * docs: clarify cache maximum, config equality, and module overviews - The JS agent checks the 12 hour cache maximum, so the limit can change without a plugin release. - FingerprintConfig equality exists because it keys cached web agents and native clients. - README shows the cache keyPrefix option. * refactor: stop waiting for create in get - The constructor still calls create early, so on Android and iOS the native client, and location collection, start as soon as possible. - get no longer waits for that create. Platform get already creates the client when missing (native getOrCreate, web _agentFor), so waiting only added a round trip and a failed create that could get stuck. - This drops the retry state. A failed early create no longer breaks get, and real create errors still surface from get. * docs: note PlatformException catch no longer works in v5 * docs: list v5 behavior changes in the migration notes - Tags must be JSON-compatible. Invalid tags and a negative timeout throw ArgumentError before identifying. - Custom endpoints still get no automatic fallback, unlike the JS agent v4 default. - Android and iOS still default to the US region. * docs: list the Kotlin Gradle plugin floor in README requirements The plugin's Kotlin code is compiled by the app's Kotlin version, and the native Android SDK is built with Kotlin 2.3 metadata. AGP 8 apps with an older Kotlin Gradle plugin fail to build. The changeset already said this. * fix: throw from the constructor when the Flutter binding is missing (#164) * fix: throw from the constructor when the Flutter binding is missing On Android and iOS the constructor starts the native client right away. Without a binding, that failed inside the stored create future: - the early native create (location warm-up) never ran - every get() rethrew a FlutterError, not a FingerprintError - the client never recovered Read ServicesBinding.instance before anything async, so the standard "call WidgetsFlutterBinding.ensureInitialized()" error is thrown where the client is created. The README now shows that call in main(). https://api.flutter.dev/flutter/widgets/WidgetsFlutterBinding/ensureInitialized.html Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QgsmqW19NX6duqtNNc9wzq * chore: tweak readme --------- Co-authored-by: Claude <noreply@anthropic.com> * test: skip NetworkError in JVM error mapping test SDK 4.1.0's obfuscated NetworkError initializer throws IllegalAccessError on the desktop JVM. * fix: name the missing-binding FlutterError ServicesBinding.instance names ServicesBinding, and release builds throw a null-check. The constructor now throws FlutterError that says to call WidgetsFlutterBinding.ensureInitialized(). * fix: run native create and get off the main thread Pigeon ran both host API methods on the platform main thread. On Android, the first client creation loads the SDK classes on the calling thread (~170 ms on an emulator, more on slow devices). Apps create the client at startup, so this blocked the UI thread during launch. - Both methods use one serial background task queue - get must move too: it waits on the client cache lock while create runs, and builds the client itself if create was skipped - A serial queue keeps create before a get sent right after it https://pub.dev/packages/pigeon#task-queue https://docs.flutter.dev/platform-integration/platform-channels#channels-and-platform-threading * docs: explain what the early client start does on each platform The docs said creating the client warms location. That only holds on iOS: - iOS: creating the client starts location collection. The iOS SDK docs recommend creating it at app startup and keeping one instance for the best location precision. - Android: creating the client loads the SDK, so the first get is faster. Location is collected during each get, up to locationTimeout. https://docs.fingerprint.com/docs/ios-sdk#using-location-data-for-proximity-detection https://docs.fingerprint.com/docs/android-sdk * docs: shorten Pigeon host API comments * docs: simplify README client and location notes * refactor!: make fingerprint_flutter.dart the only public library error.dart and result.dart only re-exported src/, and options.dart and region.dart were importable on their own. Apps only need the main library, so move the rest under src/ and export it from there. Dart treats lib/src as private to the package. https://dart.dev/tools/pub/package-layout#implementation-files * refactor!: stop exporting pluginVersion It is an internal value sent as integration info, not something apps should read. Move it to lib/src/plugin_version.dart, and read it directly on each platform instead of passing it through FingerprintConfig, where it never differs between clients. * refactor!: send region to native code as a Pigeon enum Region went to native code as a lowercase string, so Android and iOS each parsed it again and disagreed on bad input: Android threw, iOS fell back to US. A Pigeon enum makes bad input impossible and removes both parsers and the public RegionValue.stringValue extension. Web uses Region.name. Dart now sends US when region is omitted, the documented Android and iOS default. https://pub.dev/packages/pigeon#supported-datatypes * refactor: key native clients on the Pigeon config Pigeon generates value equality for FingerprintNativeConfig (Kotlin equals/hashCode, Swift Hashable), so it can be the cache key directly. This removes the hand-written ClientKey types, which copied every config field and had to be kept in sync. With the key gone, each cache was a map and one lookup, so it moves into FingerprintHostApiImpl. buildConfiguration now runs only when a client is created, not on every get. Configs that only resolve to the same SDK Configuration (no endpoint vs the region's default URL, or iOS with a different Android-only locationTimeoutMillis) now get separate clients. That is harmless. * docs: note the smaller public API in the v5 changeset * refactor!: one error code per client failure on every platform - Client failures use the JS agent code on all platforms: - bad_response_format: Android ResponseCannotBeParsed, iOS jsonParsingError and invalidResponseType - invalid_endpoint: iOS invalidURL. Android reports a malformed endpoint as a network error, so it stays network_error there. - iOS invalidURLParams becomes unknown_error. The plugin builds the integration info, so it is a plugin bug, not an app error. https://docs.fingerprint.com/reference/js-agent-v4-error-handling - Drop constants no platform can send through the plugin: the old client codes above, handle_agent_data, api_key_invalid, wrong_worker_option (typed options rule them out) and bundle_not_defined (loader internal). The strings still pass through if they ever arrive. - iOS forwards APIError.Code.rawValue, which is already the server's snake_case code. This removes the per-code list and the camelCase helper. - Document that on Android and iOS the native SDK turns server codes it does not know into its unknown error and drops the server message. The plugin cannot recover it. Android keeps the eventId. * refactor: keep FingerprintClientCache as its own class Restore the cache as a small class keyed on FingerprintNativeConfig, and move buildConfiguration into it. FingerprintHostApiImpl only maps get calls, and the cache owns turning a config into one SDK client. * feat!: rename pluginVersion to fingerprintFlutterVersion Keep the package version public so apps can log it or add it to support requests. The new name says which package it belongs to. It is exported from the main library. * refactor: keep native client codes consistent with React Native - Keep invalid_url (iOS), json_parsing_error (iOS) and response_cannot_be_parsed (Android). The React Native SDK uses the same codes, so both SDKs stay alike. - iOS invalidURLParams and invalidResponseType go to unknown_error with their message. Both are practically unreachable. - Web constants are now the codes on the public JS agent error page, minus codes for options the plugin does not expose. Drops server_error and worker_initialization_failed, which that page does not list. https://docs.fingerprint.com/reference/js-agent-v4-error-handling - Fix comments that did not match the code and trim repeated ones. * docs: explain why iOS forwards rawValue and how unlisted codes differ - iOS SDK decodes the server code into APIError.Code, so rawValue is the exact server string. That is why the plugin needs no per-code mapping. - Dartdoc now says iOS and web pass unlisted server codes through, while Android sends unknown_error with the message (no code string to forward). * fix: keep iOS invalid_url_params and invalid_response_type codes The iOS SDK's public FPError declares both cases. Whether they are thrown today is not a public contract, so map them to their own codes, the same as the React Native SDK, instead of folding them into unknown_error. * fix: keep server_error and worker_initialization_failed constants Both are in the JS agent's public ErrorCode type. Missing from the error docs page is not a promise they are never thrown, so keep them. Only drop codes the plugin itself rules out or the type marks as loader-internal. https://docs.fingerprint.com/reference/js-agent-v4-error-handling * chore: shorten readme * refactor: shorten error comments and fold UnknownError into else - Android UnknownError and Server API only types both became unknown_error, so one branch covers both. An UnknownError without a description now gets its type name as message, like the others. - Trim the error comments and changeset line. * docs: drop the error pass-through sentence from the changeset It covered edge cases that do not affect migration. The FingerprintError Dartdoc documents them. * chore: agents.md * test: point merged iOS tests at fingerprint_flutter - v5 added the iOS unit tests before the package rename. Update the test imports and the CI working directory to the new package name. - Rename FPJSErrorFlutterTests to FPErrorFlutterTests to match FPError+Flutter.swift. * test: iOS forwards the server's error code string as is Decode APIError from literal server JSON, not from APIError.Code.rawValue, so the test fails if the plugin ever sends the Swift case name or any other transform instead of the server's code. * refactor: map errors to FingerprintError only in get The Fingerprint constructor ignores the create future, so mapping its errors was dead code. get already wraps every failure, including a failed web agent start, so the web code needs one catch, not two. Also fix the FingerprintConfig equality note: only the web agent cache uses Dart equality. Native code keys clients on FingerprintNativeConfig. * refactor: build the Fingerprint config once The public fields become getters over one final FingerprintConfig, so endpoints are normalized in the initializer and _config no longer needs late. * chore: tidy the Android plugin and pubspec - FingerprintPlugin.hostApiImpl was never read. Pigeon keeps the handler alive until setUp(null). - @nonnull has no effect on Kotlin parameters. - Dart rejects negative timeouts, so the Int clamp only needs an upper bound. - Drop the plugin template comments from pubspec.yaml. meta stays: the generated Pigeon Dart code imports it. * refactor(ios): inline the event id check The "Unknown" placeholder is an Android SDK default. iOS APIError.eventId is a non-optional String decoded from the server response, so only an empty id needs to become nil. https://docs.fingerprint.com/docs/ios-sdk * test: drop duplicate web start failure test get covers start failures in 'maps a throw from start and retries', and plain-value throws in 'maps a JS value without a code'. Also say the create doc means the returned future: a missing Flutter binding still throws from the Fingerprint constructor. * test(ios): expect only an empty event id to be dropped iOS has no "Unknown" event id placeholder, see the previous commit. * refactor(android): use the SDK default location timeout Configuration.DEFAULT_LOCATION_TIMEOUT_MILLIS is public, so the plugin follows the SDK default instead of repeating 5000. * fix(android): complete get with an error if client creation throws Pigeon catches throws only in sync handlers like create. In async get a throw would escape the handler instead of completing the Dart Future. Pigeon would also send a plain exception with its class name as code and a stack trace in details, which Dart reads as eventId. Send unknown_error instead. * test(android): test Configuration mapping through FingerprintClientCache The cache builds the Configuration, so its tests cover the mapping. * refactor: turn empty ids into null only in Dart FingerprintError and FingerprintResult constructors already turn empty strings into null on every platform. Native code only drops what its SDK invents, the Android "Unknown" placeholder. * refactor: drop plugin_platform_interface Its token check stops other packages from implementing a public platform interface with `implements`. FingerprintPlatform is private to this package, so there is nothing to guard. https://pub.dev/packages/plugin_platform_interface * docs: keep platform details out of public result and error docs Users need what can be null and which codes exist. How each platform sends missing values is an internal note. * feat: reject a negative Android location timeout get already rejects a negative timeout. The Android location timeout reached the SDK as a negative millisecond value instead. The constructor now throws ArgumentError before starting the client. * refactor: make WebCacheDuration a sealed class The presets and a custom duration are different cases. A private kind enum plus a nullable seconds field made the web code compare against each preset and force-unwrap seconds. - WebCachePreset (enum) and WebCacheCustomDuration (seconds, never null) implement WebCacheDuration - WebCacheDuration.optimizeCost, .aggressive, and .custom still work - the web start options use an exhaustive switch https://dart.dev/language/class-modifiers#sealed * refactor: send one endpoints list and non-null tag keys to native - Pigeon config carries endpoints as one list instead of endpoint plus endpointFallbacks, so "fallbacks without a primary" cannot be sent. Android and iOS split it into primary and fallbacks where they build the SDK configuration. - Tags are Map<String, Object?>. Dart keys are never null, and Pigeon generates non-null keys for Kotlin and Swift, so the Android copy that dropped null keys and the Swift nil-key guard go away. https://pub.dev/packages/pigeon * refactor: drop unreachable Bool, Int, and Double casts in jsonType Swift Bool, Int, and Double values bridge to NSNumber, so the NSNumber branch already handles them, and the later casts never run. https://developer.apple.com/documentation/foundation/nsnumber * docs: note that a failed web script load stays failed start() returns the agent before its script loads, so the agents map keeps an agent whose load failed. The agent keeps that failure and rejects every later get with script_load_fail, without trying the load again. https://docs.fingerprint.com/reference/js-agent-v4-error-handling --------- Co-authored-by: Claude <noreply@anthropic.com>
The example app tags had no null or double, so E2E never ran the native tag conversion for them (iOS NSNull/nil handling, Android map passed to the SDK as is). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* docs: add v5 row to version table and fix get description * docs: say get waits for the client the constructor started On web the loader proxy awaits the bundle download inside get. On native get is queued behind create on the same serial Pigeon queue. Either way one client is created and get uses it.
* docs: fix platform attribution and worker comment in FingerprintError invalid_url, invalid_url_params, json_parsing_error, and invalid_response_type only come from FPError+Flutter.swift (iOS). response_cannot_be_parsed only comes from FingerprintNativeError.kt (Android). The old comment grouped all five as iOS+Android. workerInitializationFailed stays in the JS ErrorCode subset even though the bundled @fingerprint/agent 4.1.5 loader never creates a Worker today (checked https://cdn.jsdelivr.net/npm/@fingerprint/agent@4.1.5/dist/fp.d.ts and the minified bundle). It's part of the public ErrorCode type, and web/package.json pins `^4.1.5`, so a future 4.x release could start using a worker and emit this code. * fix(ios): declare tvOS 15 deployment target in the podspec Package.swift already declares iOS 15 and tvOS 15, and the pending v5 release notes promise tvOS 15 support, but the podspec only declared iOS 15. CocoaPods consumers building for tvOS never got a deployment target, so pod install could pick a target below what Fingerprint-iOS supports. Fingerprint-iOS's own podspec declares platforms ios/tvos, so the dependency resolves for both. * test: fix misleading comment on the failed-warm-up-create test Fingerprint's constructor throws FlutterError synchronously when the Flutter binding is missing (see FingerprintNative.create), so this test cannot be about that case. It actually covers the constructor's fire-and-forget warm-up create: a rejected create future must stay an ignored async error and not block a later get. * fix: drop tvOS podspec target, trim error code comments Flutter does not build for tvOS and the CocoaPods Flutter pod has no tvOS platform, so the podspec target could never be used or linted. Restore the note that the native client error codes match the React Native SDK. * docs: drop React Native reference from error code comments
…zing (#184) * refactor: add module comments, drop avoidable mocks, dedupe error normalizing - Add short overview comments (with docs links where a genuinely relevant page exists) to fingerprint_web.dart, the Android/iOS plugin entry points, and fingerprint_result.dart. fingerprint_error.dart and fingerprint_platform_interface.dart get no new link: the closest error docs page is JS-agent-specific and already linked further down, and no page covers the internal platform interface. - FingerprintHostApiImplTest: build a real FingerprintResponse instead of mocking it (it has a public constructor), and replace the Mockito-backed `Fingerprint by mock(...)` delegates with a hand-written UnusedFingerprint base that implements every getVisitorId overload (throwing if called), so each fake only needs to override the overloads it actually exercises. Fingerprint is a plain interface, so this needs no mocking library. Context is still mocked: it's an abstract Android framework class with a huge surface these tests never call into, and there's no Robolectric here to construct a real one. - FingerprintNativeError.kt: normalizeEventId and normalizeMessage had identical bodies (Android's SDK uses the literal "Unknown" as a placeholder for missing values); collapse them into one unknownToNull. * test: keep Mockito delegation for unused Fingerprint overloads A hand-written base stubbing all twelve getVisitorId overloads was 90 lines of noise for fakes that never call them. Keep the one-line delegation and only replace the FingerprintResponse mock with a real instance.
FingerprintPlatform.instance is a plain field initializer (= FingerprintNative()); asserting its runtime type just restates that line and can't catch a real bug. FingerprintNative's own behavior is already covered by fingerprint_native_test.dart. "create failure does not block a later get" in fingerprint_native_test.dart was inspected too, and kept: injecting a create-error-caching bug into FingerprintNative made it fail, so it is not tautological.
* fix: reject timeouts under 1 ms Timeouts are sent to native and web as whole milliseconds, so zero and sub-millisecond Durations become 0. Both native SDKs fail 0 right away with client_timeout, so reject it up front with ArgumentError. Applies to get(timeout:) and android.locationTimeout. Also document that tag validation has no depth or cycle check. * docs: state the real reason tags skip a cycle check * docs: shorten locationTimeout doc
- Export only start, the one function Dart reads. Drops handleAgentData and other unused agent code from index.js (15.0 KB to 9.9 KB). - Remove the endpoints string branch. Dart always sends a list. - Add a /*! banner with the MIT notice and the bundled agent's version and license. Terser was stripping every comment, including the agent's own copyright header, and the agent is not MIT.
Without the loader <script> tag in web/index.html, the FingerprintFlutter global is undefined and start() failed with a bare JS TypeError, reported as unknown_error. Check the global first and throw script_load_fail with the tag to add. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FWx8Z3CbJpSaw4DRicpLcx
CI checked Pigeon codegen but not the committed web bundle. Rebuild it and fail on any diff. The banner year comes from the build date, so a change on that line alone is ignored. A local rebuild of the committed bundle is byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FWx8Z3CbJpSaw4DRicpLcx
get does not wait for the start the constructor kicks off. It sends the config itself and creates the client if needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FWx8Z3CbJpSaw4DRicpLcx
JuroUhlar
force-pushed
the
uhlar/optimistic-ritchie-nodpm9
branch
from
October 1, 2026 11:16
225d576 to
b53a053
Compare
- v5 is unreleased, so the missing-loader error is part of the v5 migration notes, not a separate patch entry. - README: say get does not depend on the constructor start, without describing internals.
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The README still incorrectly states that get waits for the constructor’s warm-up operation.
Review effort: Balanced
Findings: 1
Open (1)
What changed in this PR
Improves web loader error handling, validates generated web bundles in CI, and updates documentation.
Changes:
- Reports missing web loader scripts as
script_load_fail. - Adds CI verification for generated
web/index.js. - Updates migration notes and README content.
| File | Description |
|---|---|
lib/src/fingerprint_web.dart |
Detects missing loader globals and preserves Dart errors. |
test/fingerprint_web_test.dart |
Tests missing-loader error reporting. |
.github/workflows/ci.yml |
Verifies the generated web bundle. |
.changeset/v5-api-migration.md |
Documents missing-tag behavior. |
README.md |
Updates behavior text and table formatting. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Contributor
Author
|
@copilot resolve the merge conflicts in this pull request |
…hie-nodpm9 # Conflicts: # .changeset/v5-api-migration.md # .github/workflows/ci.yml # README.md # lib/src/fingerprint_web.dart # test/fingerprint_web_test.dart Co-authored-by: JuroUhlar <11249993+JuroUhlar@users.noreply.github.com>
ilfa
previously approved these changes
Oct 2, 2026
# Conflicts: # .github/workflows/ci.yml
ilfa
approved these changes
Oct 2, 2026
Contributor
pnpm exec changesetto create a changeset. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Follow-ups from review of #179.
web/index.htmllacks the loader<script>,getused to throwunknown_errorwrapping a JSTypeError. It now throwsscript_load_failwith a message naming the tag. The check usesisUndefinedOrNull, so it is safe under dart2wasm.web/index.jsis rebuilt fromweb/index.ts. Added to the same job as the Pigeon check: pnpm + Node setup,pnpm install --frozen-lockfile && pnpm run build, thengit diff --exit-code. The license banner contains the build year, so a diff on that line alone is ignored (git diff -I). A local rebuild of the committed bundle is byte-identical.getdescription now matches the code.getdoes not depend on the constructor's start, so start failures surface onget.No separate changeset
v5 is unreleased and v4 never had
unknown_error, so a patch entry would describe a change no consumer saw. The v5 migration changeset now notes that a missing tag makesgetthrowscript_load_fail.