Skip to content

fix(web): name the missing loader script tag, verify web bundle in CI, fix README - #192

Merged
JuroUhlar merged 44 commits into
mainfrom
uhlar/optimistic-ritchie-nodpm9
Oct 2, 2026
Merged

JuroUhlar merged 44 commits into
mainfrom
uhlar/optimistic-ritchie-nodpm9

Conversation

@JuroUhlar

@JuroUhlar JuroUhlar commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Follow-ups from review of #179.

  • Web: name the missing loader script tag. If web/index.html lacks the loader <script>, get used to throw unknown_error wrapping a JS TypeError. It now throws script_load_fail with a message naming the tag. The check uses isUndefinedOrNull, so it is safe under dart2wasm.
  • CI: verify web/index.js is rebuilt from web/index.ts. Added to the same job as the Pigeon check: pnpm + Node setup, pnpm install --frozen-lockfile && pnpm run build, then git 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.
  • README: the get description now matches the code. get does not depend on the constructor's start, so start failures surface on get.

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 makes get throw script_load_fail.

JuroUhlar and others added 30 commits September 14, 2026 23:54
)

* 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
JuroUhlar and others added 4 commits October 1, 2026 11:41
- 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
JuroUhlar force-pushed the uhlar/optimistic-ritchie-nodpm9 branch from 225d576 to b53a053 Compare October 1, 2026 11:16
@JuroUhlar JuroUhlar changed the title fix(web): recover from loader failures, verify web bundle in CI, fix README fix(web): name the missing loader script tag, verify web bundle in CI, fix README Oct 1, 2026
- 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.
@JuroUhlar
JuroUhlar requested a balanced review from Copilot October 1, 2026 11:27
@JuroUhlar
JuroUhlar marked this pull request as ready for review October 1, 2026 11:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Low severity

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.

Comment thread README.md Resolved
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Base automatically changed from v5 to main October 1, 2026 13:24
@JuroUhlar

Copy link
Copy Markdown
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
ilfa previously approved these changes Oct 2, 2026
# Conflicts:
#	.github/workflows/ci.yml
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

⚠️ This PR doesn't contain any changesets. If there are user-facing changes, don't forget to run:

pnpm exec changeset

to create a changeset.

@JuroUhlar
JuroUhlar merged commit 37ae9d6 into main Oct 2, 2026
19 checks passed
@JuroUhlar
JuroUhlar deleted the uhlar/optimistic-ritchie-nodpm9 branch October 2, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants