Skip to content

Invite a friend: referral attribution across the analytics stack - #5751

Open
shai-almog wants to merge 42 commits into
masterfrom
invite-attribution
Open

Invite a friend: referral attribution across the analytics stack#5751
shai-almog wants to merge 42 commits into
masterfrom
invite-attribution

Conversation

@shai-almog

Copy link
Copy Markdown
Collaborator

Adds invite-a-friend referral attribution: mint an invite link, share it, and on
the invited device recover the invite that caused the install. Replaces what
Firebase Invites and Dynamic Links used to do, both of which have shut down.

Resolved attribution is written as persistent analytics dimensions, so every
later event — including the purchase event the framework already emits —
arrives tagged with the campaign and the referrer. Revenue and LTV per campaign
then fall out of the reports that already exist, with no new aggregation.

The server half is codenameone/BuildCloud#PENDING and is required for this to do
anything end to end.

What's here

  • com.codename1.analytics.inviteInvites, InviteRequest, Invite,
    InviteAttribution, InviteListener, the install-referrer SPI, and
    InviteButton beside ShareButton.
  • Android App Links (autoVerify) and the Play Install Referrer; iOS associated
    domains.
  • Three build hints, a PlatformFeatureCatalog entry, a developer-guide
    section, and a simulator menu for the deferred path.

Things worth a reviewer's attention

Analytics.java is not modified. resetClientId() deliberately does not
clear custom dimensions — that is right for an app's own dimensions, but the
referral ones identify an inviter, so leaving them would re-link a fresh
pseudonymous id to the same person and defeat the erasure. Rather than widening
resetClientId (which would take the app's own dimensions with it),
InviteAttributionProvider observes the client id through the init callback
Analytics already makes and erases only the cn1_* referral keys.

The package boundary is load-bearing. The catalog matches on a package
prefix, so keying one package higher would match com/codename1/analytics/Analytics
— which nearly every app references — and put the Play dependency on all of
them. That is the DatabaseConfig failure AndroidGradleBuilder.usesClass
records. Two tests pin the boundary and were confirmed to fail when the prefix
is widened.

A floor that did not exist. The plan assumed installreferrer carries a
minSdk 21 floor. Reading the actual AAR, 2.2 declares minSdkVersion 8, so no
floor is set — adding one would have dropped API 19–20 devices for nothing.

Two match types are exact and one is not. MATCH_DIRECT and
MATCH_REFERRER are exact. MATCH_FINGERPRINT is a statistical match made
server-side because the App Store carries no referrer, and it is occasionally
wrong. The docs say so and advise against paying a referral bounty on it
without disclosure.

One unrelated commit. 9a70c18 repairs a cast-semantics baseline failure
that exists on master independently of this work — #5746 renumbered an
anonymous class in AndroidImplementation from $46 to $47. Happy to split
it out.

Verification

  • Core: 6,646 tests, SpotBugs 0 findings, core-unittests verify BUILD SUCCESS
  • Android module verify BUILD SUCCESS
  • Copyright, ASCII, /// docs, no-@since, package-info, control characters,
    cast semantics, build-hint catalog (ratchet still empty) — all green
  • Vale, paragraph capitalization, guide structure, xrefs and snippet compilation
    green for the new guide section

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9eec6539a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T11:20:54.671908Z 018119b New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Developer Guide build artifacts are available for download from this workflow run:

Developer Guide quality checks:

  • AsciiDoc linter: No issues found (report)
  • Vale: No alerts found (report)
  • Paragraph capitalization: No paragraph capitalization issues (report)
  • LanguageTool: 1 advisory match(es) — top: AN_INVITE (1) (report)
  • Image references: No unused images detected (report)

@shai-almog

shai-almog commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 12 screenshots: 12 matched.
✅ JavaSE simulator integration screenshots matched stored baselines.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Cloudflare Preview

@shai-almog

shai-almog commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 151 screenshots: 151 matched.

Native Android coverage

  • 📊 Line coverage: 9.24% (9184/99417 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 8.99% (47193/524920), branch 3.55% (1767/49739), complexity 3.52% (1868/53026), method 5.43% (1514/27888), class 10.89% (407/3736)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6367 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_0e20598.asm.org.jacoco.agent.rt.internal_0e20598.asm.ClassReader – 0.00% (0/1524 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1187 lines covered)
      • org.jacoco.agent.rt.internal_0e20598.asm.org.jacoco.agent.rt.internal_0e20598.asm.MethodWriter – 0.00% (0/922 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/736 lines covered)
      • com.google.common.cache.com.google.common.cache.LocalCache$Segment – 0.00% (0/726 lines covered)
      • okio.okio.Buffer – 0.00% (0/687 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/625 lines covered)
      • org.jacoco.agent.rt.internal_0e20598.asm.org.jacoco.agent.rt.internal_0e20598.asm.Frame – 0.00% (0/570 lines covered)

✅ Native Android screenshot tests passed.

Native Android coverage

  • 📊 Line coverage: 9.24% (9184/99417 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 8.99% (47193/524920), branch 3.55% (1767/49739), complexity 3.52% (1868/53026), method 5.43% (1514/27888), class 10.89% (407/3736)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6367 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_0e20598.asm.org.jacoco.agent.rt.internal_0e20598.asm.ClassReader – 0.00% (0/1524 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1187 lines covered)
      • org.jacoco.agent.rt.internal_0e20598.asm.org.jacoco.agent.rt.internal_0e20598.asm.MethodWriter – 0.00% (0/922 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/736 lines covered)
      • com.google.common.cache.com.google.common.cache.LocalCache$Segment – 0.00% (0/726 lines covered)
      • okio.okio.Buffer – 0.00% (0/687 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/625 lines covered)
      • org.jacoco.agent.rt.internal_0e20598.asm.org.jacoco.agent.rt.internal_0e20598.asm.Frame – 0.00% (0/570 lines covered)

Benchmark Results

Detailed Performance Metrics

Metric Duration
SIMD kernel backend scalar fallback (no native SIMD)
SIMD int-add (64K x300) java 140ms / native 59ms = 2.3x speedup
SIMD float-mul (64K x300) java 162ms / native 67ms = 2.4x speedup
SIMD kernel correctness PASS (native result == scalar reference)
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 SIMD byte path gated to scalar (CPU autovectorizes scalar; explicit SIMD not beneficial here)
Base64 CN1 encode 90.000 ms
Base64 CN1 decode 95.000 ms
Base64 native encode 387.000 ms
Base64 encode ratio (CN1/native) 0.233x (76.7% faster)
Base64 native decode 316.000 ms
Base64 decode ratio (CN1/native) 0.301x (69.9% faster)
Image encode benchmark status skipped (SIMD unsupported)

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Continuous Quality Report

Test & Coverage

Static Analysis

Generated automatically by the PR CI workflow.

Adds com.codename1.analytics.invite: mint an invite link, share it through
the native share sheet, and on the invited device recover the invite that
caused the install. Resolved attribution is written as persistent analytics
dimensions, so every later event -- including the purchase event the
framework already emits -- carries the campaign and the referrer.

The package boundary is load-bearing, not cosmetic. The PlatformFeatureCatalog
entry that buys the Play Install Referrer library also raises the application's
minimum API level to 21, and the catalog matches on a package prefix. Keyed one
package higher it would match com/codename1/analytics/Analytics, which nearly
every application references, and put that dependency and that floor on all of
them -- the DatabaseConfig failure AndroidGradleBuilder.usesClass records, which
deleting the unused sources later does not undo. Two tests pin the boundary and
were confirmed to fail when the prefix is widened.

Analytics.java is not modified. resetClientId() does not clear custom
dimensions, which is right for an application's own dimensions but would leave
the referral dimensions behind and re-link a fresh pseudonymous id to the same
inviter. InviteAttributionProvider observes the client id through the init
callback Analytics already makes, and erases only the referral dimensions.
Adds 33 unit tests over the invite client: minting offline, url and referrer
parsing, the funnel events, the consent state machine, erasure, and
exactly-once delivery.

Three of them are the ones worth keeping honest about:

- resetClientId must clear the referral dimensions AND leave the
  application's own dimensions alone. Both halves are asserted, because
  either one alone is a bug.
- Opt-out consent mode alone must not authorise the statistical match. The
  deprecated AnalyticsService forces that mode, so the ordinary gate reports
  permission with no user choice on record.
- A dismissed share sheet must never report invite_shared, which is what
  makes the shared count a measurement rather than an assumption.

The referrer key is compared with equals and never case folded, and a test
pins that a differently cased key does not match: String.toLowerCase is
locale sensitive with no root-locale overload in this runtime, so a folded
comparison silently stops matching under a Turkish default locale.

Ten SpotBugs findings and four cast-semantics findings in the new code are
fixed rather than excluded. The one exclusion added is scoped to
Invites$InviteConnection, a one-shot ConnectionRequest that is never compared
or used as a map key -- the same idiom and reasoning as the existing
OsrmRouteService$RouteConnection entry. The casts were rewritten into the
positive instanceof form the verifier recognises, which matters beyond the
gate: ParparVM does not throw on a failed cast, so the surrounding
catch(Throwable) would never have run on iOS.
Three hints, all in the catalog rather than on the annotations, so each stays
beside the hint it composes with -- ios.associatedDomains and
android.xintent_filter are both catalog-only today, and splitting one
feature's hints across two declaration files is how they drift.

invite.domain is deliberately platform-general: both builders read it, and the
link service it names has to agree with the apple-app-site-association and
assetlinks.json served from that host.

There is no hint to turn invites on. The class scan is the switch, through the
PlatformFeatureCatalog entry -- a second source of truth for the same fact is a
second thing to keep in sync.

android.invite.signingFingerprint carries the Play App Signing warning in its
own doc text because the failure has no other surface: Google re-signs the app,
so verifying against the upload key the build holds means autoVerify fails on
every Play install, the link opens Chrome, and nothing reports an error.
Both builders now detect com.codename1.analytics.invite (and the InviteButton
that fronts it) in the class scan, and wire the platform side.

Android gets an autoVerify App Links intent filter, appended to
android.xintent_filter rather than emitted at a new manifest site. That hint is
already rendered inside the main <activity>, and rendered a second time into
the wear companion manifest, so one append reaches both and cannot drift the
way two injection sites would. It is the repo's first use of autoVerify.

The build is REFUSED when android.activity.launchMode is "standard", rather
than warned. With singleTop (the default) or singleTask a link reaches the
running activity through onNewIntent; with standard it starts a second activity
and the invite is simply lost. A warning in a build log is the thing nobody
reads, and the symptom on the device is a feature that silently never fires.

iOS appends applinks:<host> to ios.associatedDomains. The placement is
load-bearing and commented as such: the block that uncomments
CN1_HANDLE_UNIVERSAL_LINKS tests only whether that hint is non-null, so
appending one line later would leave the define commented out -- entitlement
present, handler not compiled in, every link opening Safari. The matching
associated-domains entitlement is derived from the same hint downstream, so it
is deliberately not written separately: a duplicate key fails codesigning.

Both duplicate-suppression checks compare whole delimited tokens rather than
substrings, because the failure is asymmetric and silent -- a developer's
staging entry for a longer host would otherwise read as declaring the
production one, and they would ship an app whose invite links open the browser.
Thirteen tests cover it, and the staging case was confirmed to fail against a
naive contains() implementation.
…that was never there

Adds the deterministic Android path. The link service puts cn1_invite=<code>
on the Play url, the store hands it back on first launch, and the code is
claimed verbatim -- no matching, no guessing.

The implementation is a port source excluded from the port jar's compile and
compiled inside the generated app, the mechanism ar/ai/cipher/nearby already
use, with the builder deleting the package for apps that did not ask.
Deliberately not a generated string literal like the Firebase bridge: this owns
a connection lifecycle, a reconnect path, a bounded retry and once-only
bookkeeping, and as a literal it would be invisible to review and to SpotBugs.
Registration is spliced beside the Firebase one as a direct symbol reference,
so R8 renames call site and target together and there is no keep rule to
forget.

FEATURE_NOT_SUPPORTED -- no Play Store, a sideload, another vendor's store --
is surfaced as an ordinary "no referral" answer, not an error and not silence.

The correction: the plan asserted this dependency carries a minSdk 21 floor,
and it does not. Reading the actual artifact rather than trusting the
assumption, installreferrer 2.2 (the newest release) declares minSdkVersion 8
in its own manifest. The catalog entry now sets no floor, because adding one
would have dropped API 19 and 20 devices from every invite app's Play listing
for no reason. The aar also contributes its own
BIND_GET_INSTALL_REFERRER_SERVICE permission, so none is declared here.

The package boundary still earns its keep -- it keeps the dependency and that
permission off every app that merely reports analytics.
A new Analytics chapter section covering sending, receiving, closing the
funnel, and the build wiring, with three compilable snippets.

Two things it says plainly rather than glossing:

The three match types are not equally trustworthy, and the section says which
is which. MATCH_DIRECT and MATCH_REFERRER are exact; MATCH_FINGERPRINT is a
statistical match, used because the App Store carries no referrer parameter of
its own, and it is occasionally wrong. The advice is to report it as an
estimate and not to pay a referral bounty on it without saying so.

A coarse device profile is written to local storage on first launch, before
consent, so a deferred match is still possible if consent arrives in time. The
section says so, says it is never transmitted while consent is withheld and is
deleted if consent is refused, and says why there is no alternative that also
works -- the match window closes long before a consent prompt is answered.

The Play App Signing warning is repeated here because that failure has no other
surface: verification runs against the certificate the installed APK is signed
with, which under Play App Signing is Google's key rather than the upload key,
and getting it wrong means every invite link opens the browser with nothing
reporting an error.

Vale, paragraph capitalization, guide structure, xrefs, code blocks and snippet
validation all pass, and the snippets compile.
"Send App Argument" already covers the installed-app half -- paste an invite
link into it. What it cannot reach is the deferred half, which is the one most
likely to ship broken: the install-referrer parser is otherwise exercised only
by a real Play install, on a real device, once.

The menu feeds the parser the exact string the link service puts on the Play
url, so what runs is the production path rather than a stand-in.

"Clear Invite Attribution State" exists because attribution is deliberately
once-per-install. Without it a developer can test the first-launch path exactly
once per machine, which is precisely how once-only bugs reach production.

Added to BOTH simulateMenu assembly sites. The menu is built in one place and
rebuilt from scratch in another, so an item added to only one of them silently
does not exist on the other path.
The two ports were asymmetric here, and silently so.

iOS routes every deep link through Display.setProperty("AppArg", url), which
fires Navigation.dispatchExternalUrl. Android's onNewIntent only stored the
intent, and getAppArg() then derived the value lazily through the
implementation's own setAppArg -- so setProperty never ran and the router never
fired. Anything built on @route therefore worked on iOS and did nothing on
Android. That does not surface as a bug report; it surfaces as a feature that
"just doesn't convert" on one platform.

Deliberately narrow: only ACTION_VIEW with an http or https scheme goes through
the new path. EXTRA_TEXT shares, content:// attachments and EXTRA_STREAM
payloads keep their existing lazy route. Dispatching for every intent would
double-fire against the setAppArg inside getAppArg and change behaviour for
every share-target application already in the field.

Invite attribution does not depend on this -- Invites.checkForInvite reads the
launch argument directly, which is the one path that behaves the same on both
ports, and it was written that way BECAUSE of this asymmetry. This fixes the
asymmetry itself, for everything else built on the router.
@shai-almog

shai-almog commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 181 screenshots: 181 matched.
✅ JavaScript-port screenshot tests passed.

Five review findings and fifteen PMD violations.

Consent: a restart before the user answered the prompt destroyed the deferred
profile. Analytics.addProvider synthesizes AnalyticsConsent.denied() for the
null state, and this provider is registered on every facade entry, so a second
launch before any choice arrived looking exactly like an explicit refusal --
deleting the profile captured on the first launch and moving to DECLINED, from
which a later grant could never resume. The provider now asks
Analytics.getConsent(), which returns null until a real choice is on record,
instead of believing the argument.

Outbox: entries were cleared at send time, so a registration that never landed
was never retried. The registration carries the campaign, channel, payload and
preview metadata, and a click cannot reconstruct any of it -- and the case that
lost it is the offline mint, which is the reason minting is offline at all. Each
entry is now retired by its own successful response.

flush() only drained registrations. A deferred lookup that failed because the
first launch was offline left deferredStarted set with nothing to clear it, so
the documented connectivity-recovery call silently left the attribution
unresolved until the next cold start. It now restarts the pending lookup, still
bounded by the persisted attempt counter.

Custom parameters did not survive a restart, so an answer that arrived before
the listener registered was delivered on the next launch stripped of the data
the app acts on. They are serialized into the durable record.

Invite.isRegistered() could never return true: the value is captured when the
invite is minted and registration completes asynchronously afterwards, so the
flag could only ever report what it was constructed with, contradicting its own
documentation. Removed, and replaced with Invites.isRegistered(Invite), which
reads the outbox and can actually answer.

PMD: redundant public on interface methods, six indexed loops, two missing
@OverRide. The two NonThreadSafeSingleton findings are lazy-init caches, not
singletons; they are guarded by a load flag rather than by a null check on the
field, which is both what PMD wants and more correct -- "no attribution" and
STATE_NONE are real answers, so a null check would re-read storage on every call
for the uninvited majority. No locking was added: this facade runs on the EDT.

6,649 tests pass, SpotBugs 0, PMD 0 on the invite sources.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47a8945b01

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java Outdated
@shai-almog

shai-almog commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 148 screenshots: 148 matched.
✅ Native Mac screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 290 seconds

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 57ms / native 2ms = 28.5x speedup
SIMD float-mul (64K x300) java 54ms / native 3ms = 18.0x speedup
SIMD kernel correctness PASS (native result == scalar reference)
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 SIMD byte path active (NEON-accelerated)
Base64 CN1 encode 157.000 ms
Base64 CN1 decode 92.000 ms
Base64 native encode 679.000 ms
Base64 encode ratio (CN1/native) 0.231x (76.9% faster)
Base64 native decode 236.000 ms
Base64 decode ratio (CN1/native) 0.390x (61.0% faster)
Base64 SIMD encode 47.000 ms
Base64 encode ratio (SIMD/CN1) 0.299x (70.1% faster)
Base64 SIMD decode 44.000 ms
Base64 decode ratio (SIMD/CN1) 0.478x (52.2% faster)
Base64 encode ratio (SIMD/native) 0.069x (93.1% faster)
Base64 decode ratio (SIMD/native) 0.186x (81.4% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 7.000 ms
Image createMask (SIMD on) 3.000 ms
Image createMask ratio (SIMD on/off) 0.429x (57.1% faster)
Image applyMask (SIMD off) 36.000 ms
Image applyMask (SIMD on) 27.000 ms
Image applyMask ratio (SIMD on/off) 0.750x (25.0% faster)
Image modifyAlpha (SIMD off) 27.000 ms
Image modifyAlpha (SIMD on) 21.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.778x (22.2% faster)
Image modifyAlpha removeColor (SIMD off) 26.000 ms
Image modifyAlpha removeColor (SIMD on) 21.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.808x (19.2% faster)

…r read

Six findings on this PR, all valid.

The client never read invite.domain. The builders generate the Android intent
filter and the iOS associated domain from that hint, but getLinkBase() only ever
consulted cloudServerURL and the default -- so an app that set a custom host
minted links for cloud.codenameone.com while its own app-links registration
named something else, and the installed app never opened its own links with
nothing reporting an error. Both builders now stamp the resolved host into the
app and the client reads it, so the two cannot disagree.

The deferred profile was written before the consent check. pendingRecord()
persists on the spot and onConsentChanged only deletes a record that already
exists when it runs, so a user who had ALREADY refused got a profile written on
their next launch and it stayed indefinitely -- contradicting the documented
promise that a refused profile is deleted. An explicit refusal now writes
nothing at all. An unset choice still captures, which is the point: the match
window closes long before a prompt is answered.

A terminal no-match was not durable. resolved:false only updated memory, so
loadState() resurrected the lookup on every launch and an ordinary uninvited
install re-queried the server and re-fired attributionUnavailable for ever.

Storage.writeObject's result was ignored. Storage was chosen over Preferences
precisely because it reports a failed write; deleting the pending record after
one left neither an attribution nor any retry information.

Re-attribution left stale dimensions: a later invite with no campaign kept the
previous one, so events carried the new code beside the old campaign.

A transient Play Store failure burned the once-only flag, so a later flush
skipped the deterministic referrer for ever and fell back to a guess. Only
terminal outcomes are recorded now.

One test failed and deserved to. It used AnalyticsConsent.none() to mean "not
decided yet", but none() is an explicit refusal; the fix exposed that the test
encoded the wrong semantics. Split into undecided (null) and refused.

Vale caught what a narrower local run did not: the build hint doc strings are
rendered into the generated guide table and linted there. Fixed at source; the
whole guide is clean across 123 files.

6,650 tests pass, SpotBugs 0.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c43f5c2bf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/InviteStore.java Outdated
Four findings, all valid.

A response already on the wire could undo a privacy operation. When consent is
withdrawn or resetClientId() runs, both delete the pending record and clear the
referral dimensions -- but the claim or match request they raced still arrived,
and resolve() wrote the attribution and dimensions straight back under the fresh
identity. Every lookup now carries the epoch it was issued under and a response
whose epoch no longer matches is dropped, with the permission re-checked as
well. Two tests cover it.

The direct-link path had no denial guard. The earlier fix put one in
beginDeferred(), but checkForInvite() treats a consumed URL as handled and skips
that entirely -- so a refused user opening an invite link still had a profile
persisted, by the other route. Same guard, both entry points.

The outbox cap silently discarded unacknowledged registrations. Once entries
were retired on acknowledgement rather than at send time, evicting the oldest
became a way to lose an invite whose link had already been shared: the code
carries no inviter, campaign, payload or parameters, so a later click can never
be joined to any of it. The ceiling is now 512 rather than 32, and breaching it
is logged rather than silent. I am keeping a ceiling -- an unbounded on-device
queue is not something to ship -- but it is now far outside anything the design
contemplates.

The Android filter claimed every invite link on the shared domain. This is the
Android twin of the apple-app-site-association collision the slug already solves
on iOS: a bare /i/ prefix makes every invite-enabled app an eligible handler for
every invite url, so Android shows a chooser or opens the wrong app, and the
slug inside the path cannot disambiguate because the filter accepts them all.
The new invite.slug hint scopes it to /i/<slug>/. Without a slug the broad
filter is still emitted and the hint documents why -- a filter matching nothing
would be worse.

6,652 tests pass, SpotBugs 0, the whole guide is Vale-clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e63dfeddcc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/InviteStore.java Outdated
A non-2xx response reached postResponse() exactly as a 200 did --
ConnectionRequest reads error bodies by default and the error path falls
through -- so a transient 5xx retired the durable registration as though the
server had accepted it, and an error body parsed as "not resolved" turned one
bad minute upstream into a permanent "you were not invited". Gate both on the
status.

The build scoped the Android filter and the iOS path claim to /i/<slug>/ but
only stamped invite.domain into the app, so the client learned the slug from
the link service -- which the first invite is minted before ever reaching. That
first link could not match the build's own filter. Stamp the slug too, and let
it outrank the stored value.

A terminal no-match deleted the pending record, and an absent record reads back
as STATE_NONE: the next launch built a fresh profile and asked again, for ever.
Replace it with a marker that carries the state and nothing else -- durable, and
holding none of the profile, which existed to be matched and now has nothing to
match against.

Under re-attribution a pending claim lost to the older resolved attribution in
loadState(), so a claim interrupted by process death was never retried and last
touch silently kept losing to first. Consult the pending record first, and only
under re-attribution: without it a stale record must never reopen a settled
attribution.

The manifest filter was suppressed by any existing filter naming the host, so an
app already routing cloud.codenameone.com/account/ never got one and its invite
links kept opening the browser. Require the path too, and accept only a prefix
that really covers /i/<slug>/.

InviteStore.writeOutbox discarded writeObject's result, so a full store lost the
campaign, channel, payload and preview of a link already handed out with no
sign. Propagate it and send that one registration immediately instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bfe54295f7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
A Play Install Referrer outage is not an answer. Two failed connection attempts
left the source's once-only flag deliberately unset so a later launch could read
the exact referrer, and then the statistical fallback's no-match settled the
install as organic anyway -- throwing away a deterministic result that was still
reachable. A transient failure now marks the record, and a no-match against that
mark stays pending, bounded by the attempt cap and the window as before.

setAttributionWindow(0) recorded nothing: setState() only rewrites a record that
exists, and on a fresh install none does, so the listener heard "unsupported" on
every launch. It writes the terminal marker now -- the one marker that carries a
reason, because it is the only terminal answer that can stop being true, and an
application that later ships a non-zero window is asking for attribution again.

The filter check searched the whole hint value, so a filter for our host on
/account/ and an unrelated host on /i/ claimed coverage between them although
neither would ever open an invite link. Host and path are matched within one
<intent-filter> now.

isRegistered() read absence from the outbox as acknowledgement, which is exactly
wrong for the registration sent directly because the outbox could not be written:
never queued, so the queue says nothing about it. Those codes are tracked in
memory until the server acknowledges them -- in memory because the durable store
is the thing that failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5610439922

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
…al one

A deferred lookup already on the wire ran under the same epoch as the direct
claim that superseded it, so both answers passed the guard and a statistical
match arriving second overwrote the exact one -- its dimensions and its durable
record with it. The direct claim advances the epoch, which is how every other
supersede in this class is expressed.

The pending branch added last round still called attributionUnavailable(), which
is the terminal callback: it says no invite will be attributed, and it sets
deliveredThisRun, so a referrer that succeeded moments later in the same process
could no longer deliver inviteReceived() -- while a relaunch could deliver it as
a second outcome after the first said never. A pending outcome now tells the
listener nothing.

Refusing consent deleted the pending record and then called setState(), which
has nothing to rewrite once the record is gone, so STATE_DECLINED lived in memory
and the listener was told again on every launch. It writes the profile-free
marker instead, at all three refusal sites. The marker carries its reason, and
beginDeferred reopens it when the reason stops being true -- a granted consent
here, a re-enabled window for the other one -- read from the condition itself
rather than from a second stored copy of it.

A successful referrer read carrying no invite is definitive, and it left an
earlier outage's referrerRetry marker in place, so the following no-match looked
retryable and every launch asked again until the attempt cap. The non-retryable
path clears it.

Two consent tests asserted the record was absent, for a promise that is about the
profile. They assert the profile fields are gone now, which is the property the
documentation actually makes and the only one that can survive a relaunch.

Also fixes the PMD NonThreadSafeSingleton that build-test (8) caught in
loadState: the record is reduced to a value before the branch, so there is no
null-check-then-static-assign shape. Not a lock -- this facade runs on the EDT
and adding one would be the real mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 57fc4c36a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
@shai-almog

shai-almog commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 160 screenshots: 160 matched.
✅ Native Mac screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 157 seconds

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 56ms / native 4ms = 14.0x speedup
SIMD float-mul (64K x300) java 58ms / native 3ms = 19.3x speedup
SIMD kernel correctness PASS (native result == scalar reference)
Base64 native bridge unavailable (CN1 + SIMD + image benchmarks only)
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 SIMD byte path active (NEON-accelerated)
Base64 CN1 encode 164.000 ms
Base64 CN1 decode 93.000 ms
Image encode benchmark iterations 100
Image createMask (SIMD off) 6.000 ms
Image createMask (SIMD on) 1.000 ms
Image createMask ratio (SIMD on/off) 0.167x (83.3% faster)
Image applyMask (SIMD off) 31.000 ms
Image applyMask (SIMD on) 22.000 ms
Image applyMask ratio (SIMD on/off) 0.710x (29.0% faster)
Image modifyAlpha (SIMD off) 25.000 ms
Image modifyAlpha (SIMD on) 46.000 ms
Image modifyAlpha ratio (SIMD on/off) 1.840x (84.0% slower)
Image modifyAlpha removeColor (SIMD off) 27.000 ms
Image modifyAlpha removeColor (SIMD on) 23.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.852x (14.8% faster)

@shai-almog

shai-almog commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 143 screenshots: 143 matched.
✅ Native iOS screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 2035 seconds

Build and Run Timing

Metric Duration
Simulator Boot 107000 ms
Simulator Boot (Run) 1000 ms
App Install 18000 ms
App Launch 25000 ms
Test Execution 675000 ms

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 79ms / native 3ms = 26.3x speedup
SIMD float-mul (64K x300) java 79ms / native 4ms = 19.7x speedup
SIMD kernel correctness PASS (native result == scalar reference)
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 SIMD byte path active (NEON-accelerated)
Base64 CN1 encode 281.000 ms
Base64 CN1 decode 125.000 ms
Base64 native encode 626.000 ms
Base64 encode ratio (CN1/native) 0.449x (55.1% faster)
Base64 native decode 613.000 ms
Base64 decode ratio (CN1/native) 0.204x (79.6% faster)
Base64 SIMD encode 97.000 ms
Base64 encode ratio (SIMD/CN1) 0.345x (65.5% faster)
Base64 SIMD decode 67.000 ms
Base64 decode ratio (SIMD/CN1) 0.536x (46.4% faster)
Base64 encode ratio (SIMD/native) 0.155x (84.5% faster)
Base64 decode ratio (SIMD/native) 0.109x (89.1% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 11.000 ms
Image createMask (SIMD on) 6.000 ms
Image createMask ratio (SIMD on/off) 0.545x (45.5% faster)
Image applyMask (SIMD off) 61.000 ms
Image applyMask (SIMD on) 296.000 ms
Image applyMask ratio (SIMD on/off) 4.852x (385.2% slower)
Image modifyAlpha (SIMD off) 380.000 ms
Image modifyAlpha (SIMD on) 381.000 ms
Image modifyAlpha ratio (SIMD on/off) 1.003x (0.3% slower)
Image modifyAlpha removeColor (SIMD off) 250.000 ms
Image modifyAlpha removeColor (SIMD on) 295.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 1.180x (18.0% slower)

A first-time denial has no prior pending record -- that is the ordinary shape of
a first launch by someone who had already refused -- so the marker copied an
absent clock and carried expiresAt 0, which beginDeferred reads as "no window".
An arbitrarily old install could then still run a fingerprint match after a
later grant. The marker starts its own clock when there is nothing to copy.

The delivery flag was written and not checked, on both sides. deliveredThisRun
suppresses duplicates only until the process exits, so telling the listener
about a delivery the device cannot remember means telling it again on the next
launch -- against the exactly-once contract. Better late, on a launch where the
flag can be written, than twice.

The attempt refund's own write was unchecked, and it fails for exactly the
reason the attribution write did. Nothing here can repair that, so it says so
rather than leaving the promised retry to be assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: abb2a1b8fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Two ways a reopened deferred lookup came back unable to answer, plus the
Android delivery that never reached the invite code at all.

A terminal marker deliberately carries no device profile -- a refusal
deletes the fingerprint, which is the promise the consent path makes --
so converting one back to pending left the resumed match sending empty
strings and zero screen dimensions. The server then had the network and
the country to score on, which is below the threshold: a consent grant
inside the original window could not recover the invite it was granted
for. The profile is now CAPTURED AGAIN on reopening rather than carried
through the refusal, which keeps both promises.

The kill-switch reopening had a second problem the first could hide. A
marker written while setAttributionWindow(0) was in force recorded
expiresAt = firstLaunch + 0, a window already over at the instant it was
created; reopening kept it and the expiry check settled the lookup again
on the same pass. Shipping a non-zero window later -- the documented way
to ask again -- could therefore never work. firstLaunch is a fact about
the install and stays; the window is a policy and the current one now
applies. The consent reopening is untouched: its marker was written
under a real window and recomputing there would change a right answer.

reenablingTheWindowReopensThatOneTerminalMarker was already asserting
this and passed anyway, because whether the state assertion catches it
depends on which sibling test ran first -- it fails on its own on master.
It now asserts the expiry on the record.

On Android, an App Link that arrives while the activity is resumed never
reaches the application's start(): the generated lifecycle returns early
when wasStopped is false, and the next onStop() clears the app arg the
port just stored. The invite was lost with nothing to show for it -- no
claim, no invite_opened. The stub now overrides onNewIntent and consumes
it on the EDT. Generated rather than done in the port, because
AndroidImplementation referencing com.codename1.analytics.invite would
make PlatformFeatureCatalog match the prefix for every application and
put a Play Install Referrer dependency and an API 21 floor on apps that
never heard of invites -- the DatabaseConfig bug, already fixed once.
AndroidInviteNewIntentTest pins that gating along with the override.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 883f9aa9fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Storage can fail, and every caller here had already changed the
in-memory state by the time it did. handleUrl() was the worst case: it
committed STATE_PENDING and issued the claim before knowing the record
reached the disk, so when the write failed and the claim failed too, the
exact code from a direct link existed nowhere. The retry then read the
stale record underneath it -- which has no code -- and answered with the
install referrer or a fingerprint instead: a guess, or nothing, for a
question the device had an exact answer to.

Every read and write of the pending record now goes through readPending()
and writePending(). A failed write keeps its record in memory and the
next read prefers it -- it is always the newer of the two, because it
exists only between a write that failed and the next one that succeeds --
and retries persisting it there, which is the next time anything wanted
the record anyway. Successful writes and every delete clear it, so it can
never shadow the disk. It does not survive the process, and cannot; that
is what the durable record is for. A transient failure is over within one
launch far more often than not.

Separately, create() failed to mark the code unacknowledged on the branch
where the outbox write failed AND consent forbade sending. isRegistered()
reads absence from both the outbox and that set as acknowledgement, so
the one invite the server is guaranteed never to have seen was the one
reported as registered -- and an application that waits for it before
sharing hands out a link with no campaign, channel or preview behind it.

Both tests were checked by reverting their fix. The pending-write one
arms the failure AFTER the record exists, so the write that fails is the
one adding the code rather than the one creating the record: that is the
case a stale disk record can shadow, and the one the earlier draft of
this test missed entirely by passing without the fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 242bad2106

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/InviteStore.java Outdated
A regression from the pending-record fallback, found by review before it
shipped. markUnavailableDelivered() puts delivered=true on the marker and
the caller withholds the callback when the write fails -- but the map
carrying that flag is the one the failure holds for retry, so the next
read persisted the very flag the failure was meant to prevent. The answer
then read as already delivered and the listener never heard it, on that
launch or any other. The flag is backed out of the map on failure, which
is the whole restore because the method returns early when it was already
set.

Separately, the outbox is capped at 512 and drops the OLDEST entry to
stay under it. isRegistered() reads absence from both the outbox and the
unacknowledged set as acknowledgement, and an evicted entry is in
neither -- so the one registration the server is guaranteed never to have
received reported itself as registered, and only a log line said
otherwise. InviteStore now hands each evicted entry to Invites before
dropping it. In memory only, like every other entry in that set; the
ERROR log remains the durable half.

The cap moved out of writeOutbox's try block to do it: copy.remove(0) on
a List<String> compiles to a CHECKCAST, ParparVM does not throw for a
failed cast, and check-cast-semantics.sh refuses a checked cast under a
catch(Throwable) because the handler cannot run on iOS. Nothing in the
cap can fail anyway -- a copy, a size comparison and a removal.

Both tests were checked by reverting their fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1dcfc6b998

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
markTerminal() deliberately does not set the in-memory state when its
write fails, so the record held for retry can be terminal while memory
still says pending. Persisting it later without saying so left the two
disagreeing: a flush read the cached pending state, treated the lookup as
live, rewrote the terminal marker back to STATE_PENDING and issued
another lookup -- with the device profile markTerminal had stripped, so
it could not have matched anyway.

readPending() invalidates the cached state when a held record reaches the
disk. Invalidating rather than assigning, because what the record means
depends on the re-attribution setting and on whether an attribution
exists, and loadState() is the one place that knows. The cost is one
extra read of a record just written, and only after a storage failure.

Checked by reverting the invalidation: the state stays PENDING while the
record on the disk says otherwise.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9fe42d0873

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
markTerminal() deliberately does not set the state when its write fails,
so the record held for retry can be terminal while memory still says
pending. loadState() now reconciles the held copy before it trusts the
cached answer, which puts it ahead of every state decision because they
all come through that method.

The review round that prompted this predicted more than measurement
supports, and the code says so where the guard is. It claimed flush()
would act on the stale answer, reopen the marker and issue a fresh lookup
without the profile markTerminal strips. Traced end to end with the
reconciliation removed: it does not. Every path that reopens or rewrites
the record reads it first, and readPending() drains the held copy and
invalidates the cache before anything is written -- flush() enters its
restart branch on the stale PENDING and still finishes with the state and
the marker both terminal.

What is real is narrower and worth fixing on its own: getState() is
public API, and answering PENDING out of a cache the device's own record
already contradicts is wrong whatever the caller does next. The first
test written for this passed WITHOUT the fix, which is what sent me to
instrument the path rather than believe it; the test now asserts the one
thing that can observe the disagreement, and fails without the guard.

Both places drain, and both have to: whichever reaches the held record
first is the one that has to invalidate, or the other finds nothing left
and trusts an answer the record has already contradicted.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c16d23644

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
The standard launch mode no longer fails the build. The guard refused it
outright, on the reasoning that a link then starts a second activity and
the invite is lost. A review round pointed at the generated stub's
`private Form currentForm` -- an INSTANCE field -- and it is right: the
second activity's copy is null, so wasStopped is true, the generated
run() reaches createStartInvocation(), and the application's start()
reads the link out of getAppArg() exactly as on a cold launch. The invite
arrives. Refusing rejected a configuration apps already build and ship
with, so it warns instead and names the colder path.

Queued registrations went out carrying the consent they were minted
under. Under the default opt-in mode an invite is usually minted BEFORE
the prompt is answered, so the serialized body says
consentAnalytics:false; draining is gated on consent, but the flag
travels with the body and the analytics transport reads it as the proof
that the gate was satisfied. Rewritten at drain time -- rewritten, not
rebuilt, because the campaign, payload and preview are what the invite
was minted with and must not be re-derived from today's state.

dispatchNewIntentUrl mutated the caller's Intent. It runs from
CodenameOneActivity.onNewIntent, and the ordinary way to extend that is
super.onNewIntent(intent) followed by reading intent.getData() -- which
had just been set to null underneath the override, so custom deep-link
routing that worked before lost the url. The consumption happens on a
copy, stored with setIntent; the object the override holds is left as the
OS handed it over.

setReattribution(false) did not stop a replacement already on the wire.
The response still passed handleResolution()'s epoch guard and overwrote
the first-touch attribution the setting had just said to keep. The epoch
bump fails it on arrival. The first attempt at this deleted the durable
replacement record too -- turningOnReattributionLetsTheStateBeReadAgain
caught that, and it is right: the off/on round trip is supported and the
record is a link the user really did open. What is cancelled is the
request, not the invite.

Each has a test checked by reverting its fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2afe6d2c96

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
reset() left the state at STATE_NONE, which is indistinguishable from a
fresh install -- so the next ordinary checkForInvite() built a new device
profile and started deferred matching again. Inside the original click
window, which on iOS is the normal path, the server can match the same
device to the same click and restore the very inviter dimensions the user
asked to be rid of, under their new client id. The erasure lasted until
the next launch.

eraseInternal() leaves a tombstone: a state and a reason and nothing
else -- no code, no fingerprint, no identifier, none of what the erasure
removed. It is marked delivered, because the answer it stands for was
already given and has just been erased, and its reason is not one
beginDeferred() reopens. A direct link still reopens attribution, since
handleUrl() overwrites the state and clears the reason. That asymmetry is
the point: somebody who erases their identity and then taps a new invite
is asking for that invite; somebody who erases it and reopens the app is
not.

setAttributionWindow(0) changed only what future calls read. A
statistical request queued a moment earlier carries the epoch it was
issued with, so its answer still landed, persisted and reported an
attribution the application had just switched off. The window is read
against the ANSWER now -- and only the deferred one, because the switch
turns off the statistical lookup and not an exact code the device is
holding. hasSavedCode() exempts one where the lookup begins and this
keeps the same exemption from the other end, which is also why it is not
an epoch bump: the epoch is global and would discard the direct claim
with it.

And the consent rewrite from the previous commit broke acknowledgement.
It passed the rewritten JSON as both the body and the outbox key, so
outbox.remove() matched nothing: every registration would be resent on
every flush for ever and isRegistered() would never become true. The body
is rewritten and the original stays the key. The test that covered the
rewrite only asserted the body it sent, which is how it got through.

Each fix has a test checked by reverting it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3b1d4c0727

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
The response guard keyed on `deferred`, and an install-referrer claim is
exact AND deferred: the code came back through the store, which is the
whole reason the Android path is the deterministic one. So
setAttributionWindow(0) dropped the best answer the device will ever
have -- the same saved-code exemption beginDeferred() honours when it
starts a lookup, broken from the returning end.

Keyed on MATCH_FINGERPRINT now, which is what the switch actually turns
off. A test covers the referrer claim landing past the switch alongside
the existing ones for the fingerprint answer being refused and the direct
claim still landing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d0e067b95

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread docs/developer-guide/Analytics.asciidoc Outdated
…oles

The erasure tombstone was written and its result ignored. If that write
failed and the process exited before any read retried the held copy, no
marker survived -- and InviteAttributionProvider had already recorded the
new client id as its baseline, so the next launch saw no change of
identity, did not erase again, and found a state indistinguishable from a
fresh install: free to start deferred attribution and be handed the same
inviter back under the new id. eraseInternal() reports whether it
persisted and the baseline moves only when it did, which costs one
repeated erasure and is the only thing here that survives the process.

A completed referrer read left its in-flight timestamp behind. Under
OPT_OUT with no choice on record the referrer read IS permitted, so a
Play install that comes back empty falls through to the statistical
match -- which needs an explicit grant and declines. onConsentChanged()
then saw a lookup still in flight, did not start the match the grant had
just permitted, and nothing retried it: the attribution stayed pending
until some unrelated flush, check or relaunch happened along.

The builder could not see invites inside a submitted library. The scan
reads the application's own classes, so a cn1lib that encapsulates
Invites left usesInvites false and lost the entire Android integration at
once -- no App Links filter, no onNewIntent splice, the install-referrer
package deleted from the generated sources, and the Play Install Referrer
dependency never selected, so the library compiled against an API nothing
had switched on. It rides the same LibraryClassPrefixScan the call and
VPN prefixes use, and feeds the feature catalog as well as the flag.

And the developer guide said setAttributionWindow(0) switches deferred
attribution off entirely, which is not what it does: an exact code the
device already holds is still claimed. The same sentence in code was
wrong in the same direction and was fixed in the previous commit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c75853806

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
…o late

Storage.deleteStorageFile reports nothing useful on either port that
matters -- Android's Context.deleteFile() and JavaSE's File.delete() both
return a boolean and neither throws -- so a delete that failed looked
exactly like one that worked. reset() cleared the caches regardless, the
tombstone was written, and the provider recorded the new client id as
fully erased while the attribution record was still on the disk. It came
back on the next launch, so getAttribution() and conversion() reported
the old referral identity under the new id and a later consent change
restored its dimensions.

InviteStore.delete() now re-checks existence rather than trusting the
call, and OVERWRITES a record that survives with an empty one -- a delete
that cannot happen at least leaves nothing behind to restore. reset()
keeps its public signature and resetVerified() carries the answer to the
one caller that needs it: an erasure is not reported complete unless the
attribution record is verifiably gone, and the provider's baseline only
moves when it is.

Separately, a fingerprint request issued just before expiresAt can sit in
the queue or on the wire past it, and only the CURRENT window was checked
-- so a late statistical answer resolved and reported invite_install
outside the window the application configured. The request carries no
expiry to the server, so the record on this device is the only place that
deadline exists; it is read against the answer now. A marker with no
expiry at all is left alone, being a record from before the window was
written rather than one that has run out.

Both have tests, each checked by reverting its fix, and both needed a new
delete-failure seam in InviteStore for the same reason the write seam
exists: a full or read-only store cannot be produced from a test.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e629d9ef14

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
resetVerified() gated on the attribution record and ignored the outbox.
The outbox holds the queued registration JSON, and that carries the OLD
client id along with the campaign, payload and preview -- so if the store
rejected deleting AND overwriting it, the erasure still reported success,
the provider advanced its baseline, and the next drainOutbox() sent a
pre-erasure registration under the new identity as soon as storage
recovered. Both identifying records are gated now; the pending record is
a lookup in progress and identifies nobody, so it is not.

And refusing a late fingerprint answer left the lookup stranded. The
ordinary flow makes one asynchronous request with no timer behind it, so
returning without terminalising left the install STATE_PENDING for ever
and the listener owed an answer it would never get -- unless the
application happened to call flush() or checkForInvite() itself. It is
settled with REASON_EXPIRED now, and a replacement is abandoned instead,
for the reason abandonReplacement() already gives: the earlier
attribution still stands, and telling a listener "no invite" about an
install it has already been told about is a contradiction rather than an
answer.

Both were introduced by the fixes in the two commits before them, and
both have tests checked by reverting them.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3330a860ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
The App Store carries no referrer of its own, so an iOS install deferred
through it could only be guessed at: a coarse device profile written to
local storage on first launch, posted to the server, and matched against
a hashed fingerprint of somebody's address inside an hour-long window.
It was occasionally wrong, it could not say which times, and it required
collecting something from people who installed nothing and consented to
nothing.

An App Clip is launched BY the invite link and receives that link
exactly, so it can hand the code to the app the person then installs.
The answer is a fact, and everything that existed to make the guess is
gone with it.

Client:
 - AppClipHandoffSource / AppClipHandoffCallback, the iOS counterpart of
   InstallReferrerSource and registered the same way by the build.
 - requestMatch() becomes requestAppClipHandoff(); the code it returns is
   claimed with source "app_clip", exactly as a referrer code is.
 - MATCH_FINGERPRINT becomes MATCH_APP_CLIP, and every match type is now
   exact -- so the response guard that refused a statistical answer past
   the kill switch or the window has nothing left to key on and goes.
   The window still governs where a lookup STARTS.
 - The device profile is not captured at all any more: no platform, OS
   version, hardware model, locale or screen size, in storage or on the
   wire. explicitlyAllowed() went with it, the strict grant having been
   about transmitting that profile.
 - No clip source, or a clip with nothing, settles the install as
   NO_MATCH -- a real and permanent answer -- rather than UNSUPPORTED,
   which is the reopenable marker the kill switch writes and would have
   every launch ask again for something that can never be there.

Builder: the invite host declares appclips: as well as applinks:.
applinks: opens an app that is already installed; appclips: is what lets
iOS offer the clip to somebody who does not have it, which is the whole
iOS path. Declaring only the first leaves that person on a Safari page.

Tests: the three cases that existed for the statistical match are gone,
the referrer fallback test now asserts the clip is asked and that nothing
is posted, and two new cases cover "the clip had nothing" and "there is
no clip on this platform". InviteTestSupport registers a clip source that
never answers, which is how "a lookup is outstanding" is still
expressible now that no network request is involved.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 25dffdd496

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// hands the code to the app the person then installs. Declaring
// only applinks: leaves that person with a Safari page and no
// way to attribute the install that follows.
String[] wanted = {"applinks:" + inviteHost, "appclips:" + inviteHost};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Generate and register the App Clip handoff implementation

Adding the appclips: associated-domain entry does not create an App Clip target or provide the shared-container reader promised by AppClipHandoffSource. A repository-wide search finds no production implementation and no call to registerAppClipHandoffSource() outside tests, so appClipSource remains null and every iOS deferred lookup immediately settles as no_match. The iOS builder must generate/package the clip and register its handoff source rather than only adding this entitlement.

Useful? React with 👍 / 👎.

Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
The pending record is gated by the erasure after all. It looks like
bookkeeping -- a state, a deadline, an attempt count -- but it also
carries the code a direct link left on the device, and a code names an
inviter. A surviving one re-links the new identity to the old invite on
the next launch, which is the thing being erased.

A failed erasure now blocks the drain. Reporting the failure was not
enough on its own: the queued entries carry the OLD client id, so the
next flush would transmit exactly what the erasure was asked to prevent
as soon as storage recovered -- an erasure that ends by sending the
erased identity to the server. flush() retries the erasure first and
drains nothing until it succeeds.

The App Clip callback checks the epoch it was issued under. The read is
asynchronous and everything that supersedes a lookup bumps that epoch, so
a code read before a direct link arrived could overwrite the newer exact
claim, and the unavailable branch could settle a lookup the answer no
longer belonged to.

And the clip's code is written down before it is sent. The claim is one
fail-silent request, a fresh install is exactly when the device is most
likely to be offline, and the clip has already cleared its own copy by
the time it answers -- so a code that lived only in the callback was gone
for good the moment that request failed. handleUrl() persists a direct
code first for the same reason.

Also: the compilable developer-guide snippet still named
MATCH_FINGERPRINT, which failed the demos build.

The first stale-callback test passed without its fix, because an erasure
deletes the records the callback would have written to. It is retargeted
at the direct-link race, which is the case where the records survive and
the epoch is the only thing standing between them.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 018119b5c5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +566 to +568
if (!consumed) {
beginDeferred();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retry failed erasure before deferred matching

When deletion of PENDING fails during resetClientId() and the application calls checkForInvite() before flush(), this still enters beginDeferred() while erasurePending is set; that method reloads the surviving code and can submit it under the new client ID, undoing the requested erasure. Fresh evidence beyond the earlier failed-erasure finding is that the new retry gate exists only in drainOutbox(), so it does not protect deferred or direct claim paths; retry or block the erasure before starting any lookup.

Useful? React with 👍 / 👎.

Comment on lines +344 to +346
Invite invite = new Invite(code, buildUrl(code), request.getCampaign(),
request.getChannel(), request.getPayload(), now);
if (!queueRegistration(invite, request)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Finish pending erasure before enqueueing a new invite

When an OUTBOX deletion failed during erasure but storage has recovered by the next create(), this successfully appends the fresh registration to the surviving queue before the unconditional flush(). drainOutbox() then retries eraseInternal() and deletes the entire queue, including the newly minted invite; because this enqueue reported success, the code is not kept in unacknowledged, so its metadata is permanently lost and isRegistered() can report true. Complete the pending erasure before enqueueing, or preserve entries created after the identity change.

Useful? React with 👍 / 👎.

Comment on lines +254 to +258
if (copy.isEmpty()) {
if (s.exists(OUTBOX)) {
s.deleteStorageFile(OUTBOX);
}
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify deletion when emptying the outbox

When the final registration is acknowledged on a port where deleteStorageFile() silently fails, this returns success without verifying that OUTBOX disappeared. The original entry consequently remains durable, so every later flush resends an already acknowledged registration and isRegistered() continues to report false; use the verified delete/empty-overwrite behavior already implemented by delete().

Useful? React with 👍 / 👎.

Comment on lines +127 to +131
@Override
public void onInstallReferrerServiceDisconnected() {
// Deliberately not reconnecting. The one retry above is the
// whole allowance; an automatic reconnect here is how a
// background service bind loop starts.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Complete the referrer callback after disconnection

If the Play Install Referrer service disconnects before onInstallReferrerSetupFinished() supplies an answer, this callback does nothing, leaving lookupIssuedAt set and deferredStarted true without ever invoking the framework callback. There is no scheduled retry, so unless the application happens to call flush() after the retry delay or relaunches, attribution remains pending and the listener receives no result; report a transient unavailable result or perform a bounded reconnect here.

Useful? React with 👍 / 👎.

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.

1 participant