Skip to content

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

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

Invite a friend: referral attribution across the analytics stack#5751
shai-almog wants to merge 28 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-09T19:25:49.413895Z b3667c2 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: No grammar matches (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/99352 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 8.99% (47193/524697), branch 3.55% (1767/49709), complexity 3.52% (1868/52998), method 5.43% (1514/27875), class 10.90% (407/3733)
    • 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/99352 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 8.99% (47193/524697), branch 3.55% (1767/49709), complexity 3.52% (1868/52998), method 5.43% (1514/27875), class 10.90% (407/3733)
    • 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 95ms / native 160ms = 0.5x speedup
SIMD float-mul (64K x300) java 180ms / native 43ms = 4.1x 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 53.000 ms
Base64 CN1 decode 71.000 ms
Base64 native encode 369.000 ms
Base64 encode ratio (CN1/native) 0.144x (85.6% faster)
Base64 native decode 260.000 ms
Base64 decode ratio (CN1/native) 0.273x (72.7% 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

  • SpotBugs [Report archive]
    • ByteCodeTranslator: 0 findings (no issues)
    • android: 0 findings (no issues)
    • build-hint-catalog: 0 findings (no issues)
    • build-hint-tools: 0 findings (no issues)
    • codenameone-maven-plugin: 0 findings (no issues)
    • core-unittests: 0 findings (no issues)
    • ios: 0 findings (no issues)
  • PMD: 0 findings (no issues) [Report archive]
  • Checkstyle: 0 findings (no issues) [Report archive]

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: 326 seconds

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 84ms / native 11ms = 7.6x speedup
SIMD float-mul (64K x300) java 49ms / native 2ms = 24.5x 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 156.000 ms
Base64 CN1 decode 95.000 ms
Base64 native encode 564.000 ms
Base64 encode ratio (CN1/native) 0.277x (72.3% faster)
Base64 native decode 209.000 ms
Base64 decode ratio (CN1/native) 0.455x (54.5% faster)
Base64 SIMD encode 50.000 ms
Base64 encode ratio (SIMD/CN1) 0.321x (67.9% faster)
Base64 SIMD decode 46.000 ms
Base64 decode ratio (SIMD/CN1) 0.484x (51.6% faster)
Base64 encode ratio (SIMD/native) 0.089x (91.1% faster)
Base64 decode ratio (SIMD/native) 0.220x (78.0% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 9.000 ms
Image createMask (SIMD on) 1.000 ms
Image createMask ratio (SIMD on/off) 0.111x (88.9% faster)
Image applyMask (SIMD off) 31.000 ms
Image applyMask (SIMD on) 23.000 ms
Image applyMask ratio (SIMD on/off) 0.742x (25.8% faster)
Image modifyAlpha (SIMD off) 23.000 ms
Image modifyAlpha (SIMD on) 19.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.826x (17.4% faster)
Image modifyAlpha removeColor (SIMD off) 27.000 ms
Image modifyAlpha removeColor (SIMD on) 21.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.778x (22.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
Comment thread CodenameOne/src/com/codename1/analytics/invite/InviteStore.java
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

@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: 43b57ce484

ℹ️ 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
…sion

flush() retried the deferred lookup under the same epoch, so a fingerprint
answer left outstanding by the previous attempt could land after the retried
referrer resolved exactly and overwrite it. Genuinely concurrent on an
application with more than one NetworkManager thread. The retry advances the
epoch, as handleUrl now does.

Granting consent after a refusal left the lookup stopped. STATE_DECLINED carries
a reopenable marker precisely because granting afterwards is a real answer, but
onConsentChanged only restarted STATE_PENDING -- so nothing happened until the
application happened to call checkForInvite() again, by which time the
attribution window may have closed.

A terminal "no invite" answer reached before a listener was registered was
dropped. The state prevents another lookup and setInviteListener only replays a
resolved attribution, so the listener got neither callback for the whole install
-- against the documented promise that an early answer is held and delivered on
registration. It is held for the run now; the state itself is durable, so a
later launch reaches the same answer through the ordinary path.

A direct link refused on consent told the listener nothing, and checkForInvite
marks the url consumed and skips the deferred path afterwards, so that was the
only chance it had.

On Android, a getInstallReferrer() that throws after the connection came up --
a service-side RemoteException -- still burned the once-only attempted flag, so
isSupported() was false for ever and a statistical no-match could settle the
install as organic for a referrer that was there all along. A throwing read is
transient and is no longer recorded as an attempt.

The manifest filter check ignored the scheme, so an http-only filter on the
invite host and path suppressed the generated https one and every invite link
kept opening the browser. The scheme is required in the same intent-filter, and
a filter naming no scheme covers nothing, which is what Android does.

Two more PMD NonThreadSafeSingleton shapes avoided the same way as loadState:
the field is read into a local before the branch. Not a lock -- this facade runs
on the EDT.

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: 5a71e814db

ℹ️ 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
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
shai-almog and others added 2 commits September 9, 2026 17:11
Analytics.resetClientId now clears dimensions under the reserved cn1_ prefix.
The invite provider is the ordinary route and does more -- it drops the durable
records too -- but a provider can be absent: Analytics.clearProviders() is
public and the deprecated AnalyticsService.init() calls it. In that window an
erasure left the referral dimensions attached to the new id, and the next
provider the application registered transmitted them. An erasure cannot depend
on who happens to be registered when it runs.

Scoped to the reserved prefix rather than clearing everything, because an
application's own plan or role dimension describes the app and not the person,
and losing it silently on an erasure would be its own surprise. The prefix is
named and documented on the method.

The Android referrer code is persisted before the claim goes out. The source has
already burned its once-only flag by the time the callback runs, so a claim that
failed left the exact code nowhere but that callback, and the next flush() fell
back to a statistical match for an answer that had been read exactly.

The failed-outbox fallback is gated on consent. drainOutbox carries that guard
and this path had none, so a storage failure was the one way an undecided or
refused user's client id and invite metadata reached the server. The registration
is lost instead, which is the correct trade: the link still attributes through
the click, and only the campaign, channel and preview metadata go with it.

The outbox-failure paths now have a test seam, because a full or read-only store
cannot be produced from a test and those paths are the ones most worth pinning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Play referrer callback read lookupEpoch when it fired rather than when the
read was issued, so an outstanding read inherited the epoch a direct link had
just advanced, passed the guard, and could overwrite the direct attribution.
Incrementing an epoch cannot invalidate a callback that does not remember which
epoch it belongs to; it captures the epoch at issue now.

flush() restarted the lookup on every call, spending an attempt with no failure
observed -- and create() calls flush() unconditionally, so five invites minted in
a row exhausted MAX_ATTEMPTS and the last one settled the install as terminal
while its own answer was still on the wire. It restarts only once the previous
attempt has aged out. Bounded by a timestamp rather than a flag cleared by a
response, because these requests are fail-silent: a failure produces no callback
at all, so a flag would never be cleared for exactly the request a retry exists
for and flush() could wedge for the rest of the process.

A refusal held for a listener that had not registered yet was not cleared when
consent was granted and the lookup resumed, so an attribution that went on to
resolve was reported to that listener as unavailable.

The held answer lived only in a static field, and the contract says "exactly one
of the two methods per install, and the answer is remembered". A resolved
attribution has carried a durable delivered flag from the start; the unavailable
answer had nothing, so an application whose deferred question was settled before
it registered a listener, in a process that then exited, got neither callback for
the life of the install. The terminal marker carries the reason and its own
delivered flag now -- which also keeps the other half of the contract, since it
is not delivered twice.

Under ConsentMode.OPT_OUT a null recorded choice is the mode's implicit allow,
not an unanswered prompt. Ignoring it meant clearing an explicit denial resumed
ordinary analytics while a declined invite lookup stayed stopped and a resolved
attribution's dimensions stayed cleared -- the two disagreeing about the same
user. The mode is consulted when there is no recorded choice.

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: 81c7aa83e2

ℹ️ 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 Outdated
A re-attribution claim that found nothing terminalized an install that already
had an attribution. That contradicted the durable record -- which still says
RESOLVED and puts the state back on the next launch -- and told the listener "no
invite" as a second, opposite callback after it had already been given one. The
earlier attribution stands.

Replacing an attribution reset the durable delivered flag, so the replacement
was delivered as a second inviteReceived(): immediately if the first had
happened in an earlier process, on the next launch if it had happened in this
one. Re-attribution rewrites the attribution, not the fact that the listener has
already been told about this install. The flag is carried across.

The expiry marker carried no reason, so once the process that reached it exited,
a late listener was told the marker's default -- no_match -- rather than that the
window had expired.

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: 15460753d4

ℹ️ 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
The Play referrer read did not count as a lookup in flight -- only claim() and
requestMatch() set the timestamp -- so a flush() during the read, which create()
issues unconditionally, treated it as stale, advanced the epoch, and the epoch
guard added last round then discarded the exact answer when it arrived. Worse
than an ordinary lost retry: the source has already burned its once-only flag by
then, so the deterministic result is gone for good and a statistical guess takes
its place.

A re-attribution claim that found nothing stopped terminalizing the install last
round, but returning was not enough: handleUrl had already written a PENDING
record for the replacement, so the install stayed pending and every later flush
and launch retried the failed replacement until the attempt cap reported
unavailable -- with the durable attribution sitting beside it the whole time. The
replacement attempt is dropped and the install goes back to resolved.

Reopening a terminal marker deleted the record of whether the listener had
already been told, so a refusal that had been delivered was followed by a
resumed lookup whose attribution was written as undelivered, and
inviteReceived() arrived as a second callback on the next launch. The fact rides
the pending record across the reopen, and the attribution reads it from there
when there is no earlier attribution to inherit from.

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: 7a767caae3

ℹ️ 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 Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
…the code

A referrer claim that timed out is persisted and retried, and the retry
hard-coded the direct-link metadata -- so the answer came back with isDeferred()
false and was recorded as invite_opened rather than invite_install, corrupting
the install funnel for exactly the deterministic results that persistence exists
to save. The pending record carries the provenance now and the retry resends
what it was.

Abandoning a re-attribution replacement is now one helper used by every way of
giving up on it. The server no-match learned to do it last round; the attempt
cap and the window expiry did not, so they wrote a terminal marker the durable
attribution contradicts and told the listener "no invite" after it had already
been given one.

A denied invite URL arriving for an install that was already attributed did the
same thing from the other direction, because the consent guard ran before the
resolved check. Those installs keep their answer.

An empty but successful Play referrer read burns the source's once-only flag, so
it is definitive -- but it reports the same reason a transient failure does, and
the lookup stayed pending until the attempt budget ran out for an answer that
had already arrived. Retryability is read from whether the source would try
again, not from the reason alone.

A URI fragment was never stripped, so an App Link arriving as
/i/acme/ABC123#section claimed a code called "ABC123#section".

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: abfb082689

ℹ️ 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
…its clock

A direct link reused whatever window and attempt budget an older deferred lookup
had left on the pending record. Opened after that lookup had expired, or after
its retries were spent, the exact code was persisted and then marked expired by
beginDeferred() before it was ever looked at -- so an answer we were holding was
never sent. It gets its own window and a fresh budget.

Reopening a terminal marker after consent restarted the attribution window from
the moment of the grant, because the marker kept no timing. A user answering the
prompt a week later would then have run a fresh fingerprint lookup and reported
invite_install for an unrelated click. The marker carries firstLaunch and
expiresAt across, and the resumed record restores them.

Those two fields are deliberately not treated as profile data by the tests that
assert a refused profile is deleted: they are clock readings, they describe no
device, and the marker never leaves it. Keeping them is what prevents the
mismatch above, so dropping them would cost privacy rather than protect it. That
assertion now names the profile fields instead of counting them, which is how it
came to be arguing against this.

The fragment is stripped once, before either branch parses the url. Doing it on
the path branch alone left the query branch -- which runs first -- claiming
"ABC123#section" from a query-style link.

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: cdb508df4f

ℹ️ 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
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
setAttributionWindow(0) turns off the deferred lookup -- the statistical one
that needs a window to mean anything -- and it was also discarding an exact code
we were already holding, reporting "unsupported" for an invite the user really
did open. The kill switch no longer applies when there is a saved code.

setReattribution did not invalidate the cached state, and loadState reads the
pending record only when re-attribution is on. A process that cached
STATE_RESOLVED before the setter ran therefore never looked at a durable
replacement again -- and setInviteListener, which most applications call first,
is enough to cache it.

The terminal marker inherited the timings a reopen carried but not the delivery
state, so a resumed lookup that then expired or found nothing told a listener in
the next process a second time. The reopen protection covered a successful
resolve and not this.

A direct link reopening the lookup left the held terminal answer in place, so a
listener registered after the link resolved was handed the stale unavailable
result -- and deliveredThisRun then suppressed the correct one. The consent
resume already cleared it; this path did not.

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: 8bfb8ebecc

ℹ️ 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
The attribution window bounds the DEFERRED lookup, and the expiry check ran
before the saved-code branch -- so an exact code we were already holding was
marked expired whenever the two coexist: a zero window, where handleUrl records
an expiry of "now", or a first claim that failed and is retried after the window
ran out. Same reasoning as the kill switch last round, applied to the other
gate.

Analytics.setConsentMode now dispatches onConsentChanged. The mode decides what
an absent choice means, so changing it changes what is permitted for a user who
has answered nothing -- and without a dispatch ordinary events resumed on the
switch while a feature that had stopped under the old mode stayed stopped, the
two disagreeing about the same user with nothing to reconcile them. It hands
over the effective consent, exactly as setConsent does, so a provider needs no
second rule for this path.

The invite provider keeps its early return for an unanswered OPT_IN prompt. That
distinction is load-bearing and I broke it in the first version of this change:
"no choice under opt-in" is not a refusal, and reporting one deletes the profile
captured on the first launch and moves to DECLINED for a user who refused
nothing. An existing test caught it.

dispatchNewIntentUrl now consumes the intent's data, as the lazy getAppArg()
path already does. CodenameOneActivity.onStop() clears the app arg, so leaving
the data on the intent meant the next read after a resume rebuilt the same url
and an application handling AppArg in start() saw the deep link a second time.

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: 9dbd595775

ℹ️ 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
…olution

The launch argument was deduplicated for the life of the install, so tapping the
same link again -- which delivers the identical string -- was ignored for ever:
the install lost its invite_opened re-engagement event, and under re-attribution
the later open could never win. It is deduplicated for the run instead, which is
what the guard was actually for: ignoring repeated reads of one delivery by an
application that calls checkForInvite from more than one place.

What made the durable guard necessary was Android handing the same launch intent
back on a later start, and both paths that read it now consume the intent's data
-- the lazy getAppArg() always did, and dispatchNewIntentUrl does as of the
previous commit -- so a stale intent no longer reproduces the argument.

writeAttribution set the resolved state even when the durable write failed.
Everything after that write assumes the record is on disk: deliverPending()
re-reads it and finds nothing, and flush() will not retry because the state says
resolved, so a valid answer was neither delivered nor asked for again until the
process restarted. It returns instead, leaving the pending record in place for
the next flush or launch.

The test for that needed a seam: a full or read-only store cannot be produced
from a test, and the paths that only run when a write fails are the ones most
worth pinning.

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: 8e45b87689

ℹ️ 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 Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
…ave happened

Remembering the last argument still could not tell two deliveries of one url
apart from two reads of one delivery, and a live process really does span both
-- an Android onNewIntent after the app is backgrounded is the ordinary case. So
the property is consumed instead: a later read sees nothing, and a genuine
second delivery sets it again and is handled. Only an invite is consumed, so an
application routing its own deep links finds its argument exactly as it arrived,
and the read happens after Display.setProperty has already fired the
external-url dispatch.

A consent update with analytics still allowed -- changing only personalization
or ad storage -- restarted the lookup, queueing a second whose answer was as
valid as the first, so the funnel event fired twice and repeated updates spent
the retry budget with nothing having failed. It restarts only when nothing is
outstanding, as flush() already did. STATE_DECLINED is exempt from that check:
the withdrawal that produced it discarded whatever was in flight, which is now
said explicitly so a later grant resumes at once rather than waiting out a retry
delay for a request nobody can act on.

Withdrawing consent during a re-attribution replacement wrote a DECLINED marker
instead of abandoning it, telling a registered listener "no invite" as a second
contradictory callback for an install that is still attributed and goes back to
resolved on the next launch.

A failed attribution write gives the attempt back. Leaving the counter at the
cap meant the next flush marked the install terminal instead of performing the
retry the previous commit promised -- so the very last response, the one most
likely to be the only one left, could never be stored.

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: 9324a403e9

ℹ️ 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/Analytics.java
…mensions

Analytics.resetClientId clears the reserved dimensions itself, which needs no
provider -- but the durable attribution and the registration outbox are ours,
and only InviteAttributionProvider.init drops them. clearProviders() is public
and the deprecated AnalyticsService.init() calls it, so an erasure really can
run with the provider absent, after which getAttribution(), conversion() and
flush() could read or transmit the old referral identity under the new client id.

Every entry point that reads or transmits stored data now re-registers the
provider first, which re-runs that hook. Analytics deliberately does not do it
for us: a reference from com.codename1.analytics to the invite package would
match the platform feature catalog's prefix and put a Play dependency and an API
floor on every application that logs a single event -- the DatabaseConfig scar
this design was shaped around.

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: e7c14df15e

ℹ️ 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
A refusal is reopenable, so everything the marker holds has to survive the
reopening -- and rebuilding the pending record from scratch lost each thing in
turn, one review round at a time: the original window, then the delivered flag,
now the direct-link code. A user who denied consent when the link arrived and
granted it afterwards had the exact claim replaced by a referrer read or a
statistical match, which can miss or credit a different click.

So the marker is converted in place: state back to PENDING, reason dropped,
everything else untouched. Three carry-over fields go with the rebuild they
existed to compensate for, and the next thing the marker learns to hold will
survive a reopening without anyone having to remember to add it.

markTerminal reports whether its write landed, and no caller commits or delivers
until it has. An unchecked write meant a storage failure still set the in-memory
state and told the listener -- so the same lookup and the same callback repeated
after every restart, or the delivery flag landed on the old pending record and
left the state at PENDING, leaving a settled lookup running again with no way to
deliver its answer.

The refusal path in handleUrl records the code before it writes the marker,
because that branch runs before the pending record exists and there would
otherwise be nothing to carry.

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: b3667c20d6

ℹ️ 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 +1289 to +1291
Map<String, String> before = InviteStore.read(InviteStore.PENDING);
InviteStore.put(done, "firstLaunch", InviteStore.get(before, "firstLaunch", null));
InviteStore.put(done, "expiresAt", InviteStore.get(before, "expiresAt", null));

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 Initialize clocks for first-time denial markers

When the first checkForInvite() occurs after analytics consent was already denied, no PENDING record exists, so both values copied here are null. If consent is granted later, beginDeferred() converts this marker to pending and reads the missing expiresAt as zero, bypassing the attribution window and allowing an arbitrarily old install to run a fingerprint match. Fresh evidence beyond the prior reopening fix is this no-prior-record path; initialize the marker's clock fields when before is null.

Useful? React with 👍 / 👎.

Comment on lines +2129 to +2131
deliveredThisRun = true;
r.put("delivered", "true");
InviteStore.write(InviteStore.ATTRIBUTION, r);

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 Persist delivery before invoking the listener

If storage is temporarily full or read-only when this write occurs, the callback is still invoked even though the durable record retains delivered=false; deliveredThisRun suppresses duplicates only until process exit, so the next launch invokes inviteReceived() again. Fresh evidence after the terminal-marker persistence fix is that this final delivery-state write remains unchecked; the analogous unavailable-callback path through markUnavailableDelivered() has the same problem.

Useful? React with 👍 / 👎.

Comment on lines +2008 to +2010
int spent = InviteStore.getInt(retry, "attempts", 0);
retry.put("attempts", String.valueOf(spent > 0 ? spent - 1 : 0));
InviteStore.write(InviteStore.PENDING, retry);

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 Ensure the refunded attempt reaches durable storage

When the attribution write failed because the store is read-only rather than because of the one-shot test seam, this unchecked write fails for the same reason, leaving the durable attempt count at MAX_ATTEMPTS. Once storage recovers, the next flush() reads that count and terminalizes the lookup instead of performing the promised retry. Fresh evidence beyond the earlier attempt-refund fix is that the refund's own persistence result is ignored.

Useful? React with 👍 / 👎.

@shai-almog

shai-almog commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 144 screenshots: 144 matched.
✅ Native Apple TV (tvOS, Metal) screenshot tests passed.

@shai-almog

shai-almog commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 149 screenshots: 149 matched.
✅ Native iOS Metal screenshot tests passed.

Benchmark Results

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

Build and Run Timing

Metric Duration
Simulator Boot 76000 ms
Simulator Boot (Run) 1000 ms
App Install 15000 ms
App Launch 0 ms
Test Execution 443000 ms

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 2580ms / native 9ms = 286.6x speedup
SIMD float-mul (64K x300) java 222ms / native 7ms = 31.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 197.000 ms
Base64 CN1 decode 100.000 ms
Base64 native encode 1125.000 ms
Base64 encode ratio (CN1/native) 0.175x (82.5% faster)
Base64 native decode 831.000 ms
Base64 decode ratio (CN1/native) 0.120x (88.0% faster)
Base64 SIMD encode 58.000 ms
Base64 encode ratio (SIMD/CN1) 0.294x (70.6% faster)
Base64 SIMD decode 49.000 ms
Base64 decode ratio (SIMD/CN1) 0.490x (51.0% faster)
Base64 encode ratio (SIMD/native) 0.052x (94.8% faster)
Base64 decode ratio (SIMD/native) 0.059x (94.1% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 8.000 ms
Image createMask (SIMD on) 2.000 ms
Image createMask ratio (SIMD on/off) 0.250x (75.0% faster)
Image applyMask (SIMD off) 40.000 ms
Image applyMask (SIMD on) 30.000 ms
Image applyMask ratio (SIMD on/off) 0.750x (25.0% faster)
Image modifyAlpha (SIMD off) 37.000 ms
Image modifyAlpha (SIMD on) 55.000 ms
Image modifyAlpha ratio (SIMD on/off) 1.486x (48.6% slower)
Image modifyAlpha removeColor (SIMD off) 58.000 ms
Image modifyAlpha removeColor (SIMD on) 107.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 1.845x (84.5% slower)

@shai-almog

shai-almog commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Compared 217 screenshots: 217 matched.
✅ Native Apple Watch (watchOS, Core Graphics) screenshot tests passed.

@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: 165 seconds

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 66ms / native 3ms = 22.0x speedup
SIMD float-mul (64K x300) java 67ms / native 3ms = 22.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 162.000 ms
Base64 CN1 decode 93.000 ms
Image encode benchmark iterations 100
Image createMask (SIMD off) 7.000 ms
Image createMask (SIMD on) 1.000 ms
Image createMask ratio (SIMD on/off) 0.143x (85.7% faster)
Image applyMask (SIMD off) 33.000 ms
Image applyMask (SIMD on) 24.000 ms
Image applyMask ratio (SIMD on/off) 0.727x (27.3% faster)
Image modifyAlpha (SIMD off) 25.000 ms
Image modifyAlpha (SIMD on) 22.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.880x (12.0% faster)
Image modifyAlpha removeColor (SIMD off) 28.000 ms
Image modifyAlpha removeColor (SIMD on) 22.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.786x (21.4% faster)

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