Skip to content

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

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

Invite a friend: referral attribution across the analytics stack#5751
shai-almog wants to merge 105 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-12T12:52:32.697069Z 3b0fa87 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% (9188/99417 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 9.00% (47232/524920), branch 3.56% (1771/49739), complexity 3.53% (1872/53026), method 5.44% (1516/27888), class 10.92% (408/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% (9188/99417 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 9.00% (47232/524920), branch 3.56% (1771/49739), complexity 3.53% (1872/53026), method 5.44% (1516/27888), class 10.92% (408/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 333ms / native 208ms = 1.6x speedup
SIMD float-mul (64K x300) java 144ms / native 98ms = 1.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 79.000 ms
Base64 CN1 decode 101.000 ms
Base64 native encode 539.000 ms
Base64 encode ratio (CN1/native) 0.147x (85.3% faster)
Base64 native decode 426.000 ms
Base64 decode ratio (CN1/native) 0.237x (76.3% 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: 333 seconds

Detailed Performance Metrics

Metric Duration
SIMD kernel backend SSE2 (x64) / NEON (arm64) native kernels
SIMD int-add (64K x300) java 74ms / native 7ms = 10.5x 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 186.000 ms
Base64 CN1 decode 92.000 ms
Base64 native encode 609.000 ms
Base64 encode ratio (CN1/native) 0.305x (69.5% faster)
Base64 native decode 268.000 ms
Base64 decode ratio (CN1/native) 0.343x (65.7% faster)
Base64 SIMD encode 48.000 ms
Base64 encode ratio (SIMD/CN1) 0.258x (74.2% faster)
Base64 SIMD decode 51.000 ms
Base64 decode ratio (SIMD/CN1) 0.554x (44.6% faster)
Base64 encode ratio (SIMD/native) 0.079x (92.1% faster)
Base64 decode ratio (SIMD/native) 0.190x (81.0% faster)
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) 41.000 ms
Image applyMask (SIMD on) 26.000 ms
Image applyMask ratio (SIMD on/off) 0.634x (36.6% faster)
Image modifyAlpha (SIMD off) 29.000 ms
Image modifyAlpha (SIMD on) 320.000 ms
Image modifyAlpha ratio (SIMD on/off) 11.034x (1003.4% slower)
Image modifyAlpha removeColor (SIMD off) 59.000 ms
Image modifyAlpha removeColor (SIMD on) 33.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.559x (44.1% 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 Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java Outdated
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 Outdated
…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: d597eecc2a

ℹ️ 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".

… raw

"https://links.example.com" is the natural thing to write in
invite.domain, and the runtime accepts it -- getLinkBase() adds the
scheme only when it is missing, so links mint correctly and nothing looks
wrong. The builders take the raw string: android:host gets the whole URL
and iOS emits applinks:https://links.example.com. Neither matches the
links being minted, so the build succeeds and every invite opens outside
the app, which is this feature's signature failure.

The hint is reduced to a bare host before any consumer sees it -- scheme,
path, query, fragment and port removed. The port belongs in the intent
filter's own attribute and an associated domain has no place for one. A
value that reduces to nothing is left alone rather than replaced by the
default: the builders report an unusable host far better than a silent
substitution nobody asked for.

This is the build-time twin of the origin check added to setLinkBase(),
and the same failure from the other end -- the runtime tolerated the
scheme, which is exactly why nothing noticed.

Probe: with the reduction removed the test reports
<https://links.example.com> where a host belongs.

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: 05fa40de47

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

Both of these are the same mistake, caught twice in one round: I fixed
the case the review named and did not enumerate its siblings.

The install referrer is acknowledged from writePending(), beside the App
Clip handoff. Keying it on the first write missed the retry inside
readPending() -- which claim() reaches on its way out -- so the record
became durable with Play's one-shot flag unburnt, and a later launch
could read the same referrer again and restore an attribution a reset had
removed. This is exactly the hole that was fixed for the clip an hour
ago; the referrer path had it too and I only fixed the one I was shown.

ALL FIVE app-group producers use appendAppGroup(). I routed three through
it and left two -- the document provider, which appends with a comma, and
one more that appends with a space -- so an invite build that also
enabled the document provider still produced the mixed list the helper
exists to prevent. Enumerated this time rather than pattern matched:
every putArgument("ios.app_groups", ...) in the file was listed, and
none now joins by hand.

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: 08fd5e5173

ℹ️ 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 Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java
shai-almog and others added 2 commits September 12, 2026 04:14
A reset before the first checkForInvite() -- an early logout, a privacy
reset -- cleared the records and left the referrer source's one-shot flag
unburnt. Play answers the same install for as long as that flag is unset,
so the next check read the original referrer and restored exactly the
attribution the reset promised to forget. The App Clip container had this
hole and was fixed; the referrer is the other half of the same shape and
I did not look for it until it was pointed out.

referrerPersisted() is discardReferrer(), and returns whether anything is
left that could answer again. The old name could not describe the
erasure path, and the old void could not gate it: the reset now refuses
when the flag did not go, like the clip.

The Android side READS THE MARKER BACK, because Preferences.set() answers
nothing. A store that refused left the marker absent for good and the
caller was told it succeeded.

handleUrl() takes https only. An application forwarding its broader deep
links could hand over myapp://cloud.codenameone.com/i/CODE or the http://
form, and a host-only test accepted both -- persisting and claiming a
code although nothing the framework mints or the platforms associate is
anything but https. The host being right is what made it look safe.

Sixteen implementors of the renamed method, found by compiling core,
javase and android rather than by grepping for the short name -- which is
how JavaSEPort was missed last time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The server stamped createdAt when the registration arrived, and an invite
minted offline registers when the network comes back. Everything that
asks "was this person here before the invite existed" then compared
against the wrong instant, so the recipient's own post-install events
fell before it and a genuine acquisition was classified as a prior user
-- dropping out of the ranking referral bounties are paid from.

The device offers its own mint time alongside the proof. It is not
trusted: the server clamps a future time and one older than the code's
TTL back to its own clock.

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

ℹ️ 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/components/InviteButton.java
…eceiver per cancelled share

Two review findings, both real.

A privacy reset during startup did not reach the referrer. resetClientId() is
observed by being a registered provider, and the provider was installed only
by an Invites entry point -- so an application that resets identity from its
own init(), before anything invite-related runs, reset it with no provider
registered. init() never saw the change and the erasure never ran. The first
checkForInvite() afterwards then found no baseline and no durable records --
the Play referrer is Play's, the handoff is the App Clip container's, neither
is ours -- adopted the already-reset id as its baseline, and attributed the
pre-reset referral to the identity the user had just asked for.

resetVerified() has discarded an unconsumed referrer for a while. Nothing was
calling it. The hook now goes in when a platform source is registered, which
both builders splice into the stub inside a Display.callSerially immediately
before the application's own init(this) -- on the EDT, with Storage up, and
still ahead of any line of application code. Only when a source is actually
being installed: passing null removes one, which is what the tests do in
teardown.

Separately, every cancelled share leaked a BroadcastReceiver.
buildShareChooserWithCallback unregisters from inside onReceive, and Android
sends nothing when the chooser is dismissed, so a cancel left the receiver
registered on the application context holding the listener, the button and
its form -- one more on every cancel, for the life of the process. It is
fixed in the port rather than in InviteButton because every ShareButton with
a result listener had it, invites or not; InviteButton only made the path
unconditional, which it has to be, because invite_shared reports the package
the user actually picked. One receiver is now reused, so a cancel replaces
the held listener instead of adding to a pile. It cannot be driven to zero
from here: knowing the chooser was dismissed is the thing Android does not
tell us.

The fields are per-instance, not static. A lazily initialised static is a
different claim, and SpotBugs reads it as a threading bug -- correctly,
because nothing here would make it safe if it were true.

Both fixes are revert-probed: with the hook removed the new test fails on
"the reset did not reach the referrer", and the writer probe in the other
repo fails the same way.

Also fixes the three forbidden PMD violations that failed build-test (8):
an iterator loop that reads as a foreach, its fully qualified Map.Entry, and
a test seam whose guarded assignment matched NonThreadSafeSingleton. The last
is rewritten as an unconditional assignment rather than given a lock -- this
runs on the EDT like the rest of the framework.

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

ℹ️ 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
…ed link once

Two review findings, both real and both revert-probed.

An ordinary reset() retry could tombstone the install. A failed reset writes
the durable InviteStore.ERASURE marker; a later successful reset cleared the
in-memory erasurePending flag and left the marker. resumeOwedErasure() reads
the MARKER, not the flag, so the next gated call read it as work still owed,
ran eraseInternal() over records that were already gone, and wrote the
permanent tombstone. The comment on the flag-clearing line describes exactly
this harm -- "turning an application's ordinary reset() into a terminal state
it never asked for" -- and only half of it had been fixed. The durable half
now goes with the flag, and a delete that fails reports the erasure
incomplete rather than leaving the two disagreeing, which is the direction
eraseInternal() already takes.

One tapped link could produce two claims. deferredStarted means "this process
started the DEFERRED path", and a direct claim from handleUrl() never sets
it: it writes the pending record, bumps the epoch and claims. The Android
onNewIntent splice queues a checkForInvite() behind the same external-url
dispatch, and now that handleUrl() consumes the argument that queued check
finds nothing to handle and fell through to the deferred path -- where the
state is PENDING and the record still holds the code. Both requests carried
the epoch handleUrl() had just bumped to, so neither was discarded: the
funnel event could be emitted twice and two of the five attempts went on one
tap. beginDeferred() now asks lookupInFlight() as well, which is the same
question resumeDeferred() and flush() already ask before they clear
deferredStarted -- this covers the path where it was never set.

Probes: with the guards disabled the two new tests fail on "the successful
reset left the owed marker behind" and "the queued check claimed the same
invite a second time".

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

Copy link
Copy Markdown

💡 Codex Review

m.invoke(appCtx, receiver, filter, Integer.valueOf(0x2));

P2 Badge Authenticate chooser-result broadcasts

When another installed app sends the predictable <package>.CN1_SHARE_CHOSEN broadcast while a share is pending—or after its chooser was cancelled, since the listener remains retained—this exported receiver accepts it without a token and reports a fabricated successful ShareResult, potentially firing invite_shared or application reward logic. API 33+ explicitly uses RECEIVER_EXPORTED, and the two-argument registration below is likewise externally reachable on older versions; setPackage() only constrains the framework-created PendingIntent, not a forged explicit broadcast. Register it as non-exported where supported and authenticate broadcasts on older releases.



P2 Badge Keep callbacks separate for overlapping share sheets

If an application starts a second share before the first chooser finishes—for example from two InviteButton instances or two direct Display.share() calls—this overwrites the only pending listener while both chooser activities use the same receiver and PendingIntent. Selecting the older chooser therefore delivers its result to the newer request and clears that listener, after which the newer chooser's result is dropped; invite events and application callbacks are associated with the wrong request. Give each chooser a callback ID/listener entry or explicitly prevent concurrent presentations.

ℹ️ 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".

shai-almog and others added 2 commits September 12, 2026 05:37
The bounded-share-receiver change was applied with a script that read and
wrote the file through Python's universal newlines, which converted all 18455
CRLF terminators to LF. That turned a 61-line change into a whole-file rewrite,
and a whole-file rewrite conflicts with anything master does to the same file
-- which is what made this PR CONFLICTING, and therefore why GitHub stopped
creating pull_request workflow runs for it: a conflicted PR has no merge ref
for them to check out. PR CI had been silent since the conflict appeared, not
because of a queue.

The file was uniformly CRLF before and is again; the real diff is 61
insertions and 7 deletions.

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: 271f17bc68

ℹ️ 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 Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
Comment thread CodenameOne/src/com/codename1/analytics/invite/Invites.java Outdated
…nd give each chooser its own callback

Three review findings.

The slug was learned from an incoming link when the build had none of its own,
and the justification that sat there did not survive reading it again: it
argued from the BARE link form, which never reaches that branch. The only urls
that do are the ones carrying a slug -- and a build whose App Links filter
claims /i/ broadly is handed another app's link on the host every enrolled app
shares. So the only thing it could ever learn from was a stranger's slug, and
every invite minted before the first registration response then advertised
their path. The two sources that remain are the build hint and the
registration response.

extractCode accepted any nonempty final path component, so /i/<anything> on
the shared host was consumed, written into PENDING and put through the claim
retries -- and on a fresh install the no-match that came back could settle
attribution before the Play referrer or the App Clip handoff was looked at.
Codes now have to be CODE_CHARS of url-safe base64, the same shape the server
checks before letting a registration create a row. What that does NOT buy is
stated in the test: 22 crafted characters still get a claim and a no-match.
This filters accidents and garbage, not an attacker. Manual short codes are
unaffected -- they are entered by the invitee and reach the claim path
directly, never through a url.

The test codes were placeholders like ABC123 that no mint could produce, so
they are now real-shaped; the slugs they sit behind are untouched.

And the chooser fix from the previous round traded one bug for another. A
single replaceable listener meant two share() calls that both present a
chooser before either reports a selection would have the second overwrite the
first: picking a target in the first chooser invoked the SECOND call's
listener, and the second result was dropped against a field already cleared.
The per-call receiver this replaced did not have that fault. Each chooser now
carries its own token in the PendingIntent and the listeners live in a map the
one receiver reads by token. An entry for a DISMISSED chooser still cannot be
reclaimed -- Android reports nothing for one -- so the map is bounded and
drops the oldest, which is the same outcome the single field gave and only for
shares that old.

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

PendingIntent pendingIntent = PendingIntent.getBroadcast(appCtx, 0, pi, piFlags);

P2 Badge Give each chooser a distinct PendingIntent identity

When two Display.share(..., listener) calls create choosers before either selection arrives, getBroadcast() uses the same request code and otherwise equivalent intent, so FLAG_UPDATE_CURRENT updates the single existing PendingIntent with the second token. Selecting from the first chooser then broadcasts the second token, invokes the second listener, and strands the first. Fresh evidence after the earlier callback-map fix is that the callbacks are now stored per token, but both choosers still share this one mutable PendingIntent; use the token as the request code or otherwise make each PendingIntent distinct.

ℹ️ 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/components/InviteButton.java Outdated
…has changed

Two review findings, both of them the same shape as a fix that came before.

The code grammar was applied to the PATH form and not the query one, so the
malformed value simply moved: a same-host ?cn1_invite=<anything> was consumed,
persisted and claimed exactly as /i/<anything> had been. Guarding the second
caller was not enough either -- there are two, and the Play referrer is
device-local and forgeable on a rooted device -- so the check now lives in one
entry point both go through. The parser underneath stays as it was and keeps
returning the raw value, because the two answer different questions: where the
value ENDS, which is the split on the first '=' and can only be observed with
a value no code could be, and whether it is a code at all.

InviteButton reuses an outstanding invite so a double tap is one invitation
rather than two codes with the first left registered and never shared. That
was keyed on the field alone, and a dismissed chooser reports nothing -- so a
cancelled share left the invite outstanding for the life of the button and
every later press shared it regardless of what the application had set since.
setCampaign() before the next press was silently ignored and the invite kept
reporting the campaign it was minted under. Reuse is now keyed on the request:
unchanged campaign, channel and payload reuse, and any change mints afresh.
The class javadoc said "mints a fresh invite on every press", which the reuse
has never done, so it now describes what the button actually does.

The test codes carried in referrer and query strings are real-shaped for the
same reason the path ones were.

Both revert-probed: without the fixes the new tests fail on "a short word in
the query was accepted as an invite code" and "the campaign changed and the
press reused the old invite".

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

PendingIntent pendingIntent = PendingIntent.getBroadcast(appCtx, 0, pi, piFlags);

P2 Badge Give each chooser a distinct PendingIntent identity

When two API-22+ share choosers coexist, both callbacks still use the same broadcast PendingIntent. Fresh evidence after the earlier callback-state report is that the new map has per-call tokens, but this line creates every PendingIntent with the same action and request code 0; Android does not include extras in PendingIntent identity, so FLAG_UPDATE_CURRENT replaces the first chooser's token with the second one's. Selecting from the first chooser can therefore invoke the second listener, while its own listener remains stranded; use the token as the request code or otherwise make each PendingIntent identity unique.

ℹ️ 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".

…uessable

Two findings codex raised in a review BODY and a PR issue comment rather than
a review thread, so nothing that watches reviewThreads ever saw them.

The per-chooser token map did not actually give each chooser its own callback.
The token went into the broadcast Intent's EXTRAS, and Android does not
include extras in PendingIntent identity -- with request code 0 and one action
per package, every share resolved to the SAME PendingIntent and
FLAG_UPDATE_CURRENT overwrote the first chooser's token with the second's.
Selecting from the first chooser then delivered the second token, invoked the
second listener and stranded the first, which is precisely the bug the map was
added to fix. The token is now the REQUEST CODE, which is part of that
identity.

The comment there asserted the opposite -- that FLAG_UPDATE_CURRENT hands the
PendingIntent back "with this chooser's extras, and only one chooser is ever
up at a time". Neither half was true, and the second assumed away the very
case being defended against. It now says what actually happens.

Separately the token was a counter starting at 1, and the receiver is exported
-- RECEIVER_EXPORTED on API 33+, and the two-argument registration is
externally reachable on older releases. setPackage() constrains the
PendingIntent the framework creates and does nothing to a forged explicit
broadcast, so any installed app could send <package>.CN1_SHARE_CHOSEN with
token 1 and have a fabricated successful ShareResult reported: invite_shared
for a share that never happened, plus whatever an application hangs off its
own listener. The token is now SecureRandom, and onReceive already returns
when the token is not one this process issued, so forgery has to guess a
32-bit value that never leaves the PendingIntent.

Registering the receiver non-exported on API 33+ is the better answer and is
deliberately NOT done here. A PendingIntent broadcast carries this app's own
identity so it should still arrive -- but the failure mode if that reasoning
is wrong is silent, the callback simply stopping and invite_shared with it,
and this port has no test that would catch it. That wants a device. The
comment says so rather than leaving the omission to look like an oversight.

Verified by compiling the android module with SpotBugs; there is no unit-test
path for the port, so the behaviour itself is not covered here.

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: 0e7d89e1e9

ℹ️ 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
… real acknowledgement

Two more findings, from a review whose body carried them rather than a thread.

The host check is the entire basis for believing a link is ours, and it read
the authority by scanning to the first delimiter -- including ':'. A port does
not end an authority, so
https://cloud.codenameone.com:x@evil.example/i/<code> stopped at the colon and
answered cloud.codenameone.com, while the authority rules put the real host
after the '@'. Any application that forwards urls to handleUrl() would consume,
persist and claim a foreign invite. The authority is now taken whole, userinfo
is refused outright -- nothing this framework mints carries any, and refusing
does not depend on getting the rest of the grammar right -- and the port is
stripped from the host rather than mistaken for its end.

The durable outbox entry was retired on any response that was not a failure.
The review described a mint answering 200 with {"registered":false}; that is
not a shape this service produces -- every refusal is a 403 and the response
has no such field -- so checking for it would constrain nothing. What does
happen is a 200 that is not ours at all: a captive portal or an interposing
proxy answering the POST with its own page, indistinguishable from success at
the status line, and exactly the network a queued offline mint is retried on.
That dropped a registration the service never saw, permanently, while
isRegistered() went on reporting it as landed. The entry is now retired only
when the body names its code, which the mint response echoes.

The fixture that fed {"registered":true} was where that field came from; it
never existed on the wire, and it made the test pass for a reason the
production code could not rely on. It now feeds the response the service
really sends.

One existing test needed a clean start: it asserts the whole outbox is empty
rather than that its own entry went, which only held while any 200 retired
everything.

Both probed: with the fixes reverted the tests fail on "a foreign host wearing
ours as userinfo was accepted" and "a 200 that was not the mint endpoint
retired the registration".

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

ℹ️ 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
…he core code grammar

Display.share(..., listener) never answered on API 21. The chooser callback
exists only from API 22, and the pre-22 branch built a plain chooser and
returned -- so Invites.share() emitted no invite_shared and InviteButton kept
its invite outstanding for the life of the button, waiting for an outcome that
could not arrive. API 21 is not hypothetical for this feature: the platform
feature catalog lifts minSdk to exactly 21 for the Play install referrer, so it
is the floor an app using invites runs at. The branch now synthesizes
sharedTo(null), which is the same thing the failure path beside it already does
and the honest answer -- a plain chooser does not report the target.

The generated App Clip accepted any 1-64 url-safe characters while the core
requires exactly CODE_CHARS. The clip has ONE handoff slot, so a second
invocation carrying a malformed segment overwrote a valid invite already
recorded, and the full app then persisted and claimed the malformed one,
settling the install as no-match with the real invite gone. The two have to
agree because one writes what the other reads, so the clip now enforces the
same exact length. CODE_CHARS is restated in the builder rather than imported
-- it does not depend on the core -- and the test asserts the value so the
restatement cannot drift silently.

Probed: with the range restored the test fails on "the clip does not require
the core's exact code length".

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

ℹ️ 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 Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java Outdated
…s query key in the clip

Two halves that had drifted from the halves they pair with.

discardReferrer() wrote the attempted marker and nothing else, and it can be
the FIRST thing that ever touches these preferences -- Invites.reset() erases
before anything has called isSupported(). The marker was therefore stored with
no install time beside it, and when Auto Backup restored the pair into a new
installation, forgetAflagRestoredFromAnotherInstallation() read an unknown
install time, stamped the current one and returned: the restored marker stayed
set and that installation's exact Play referrer was skipped permanently. The
install time now goes with the marker, batched for the same reason the restore
path batches -- two per-key writes leave an in-between for the process to die
in.

The generated App Clip looked for a "code" query parameter while the core
parser accepts "cn1_invite". A query-form link -- /i/acme?cn1_invite=<code>,
which Invites.extractCode() takes -- fell through to the path, treated the
SLUG as the code, and wrote no handoff, so the full app installed afterwards
had nothing to claim. Note this got worse with the grammar fix one commit ago:
before it, "acme" was short enough to be accepted and a bogus handoff was
written instead. Both are wrong; the key is now the core's.

Probed: with the old key restored the test fails on "the clip does not read
the core's query key".

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: 79107df03d

ℹ️ 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/InviteAttributionProvider.java Outdated
Two failures that met in the same place.

The baseline lived in Preferences, whose set() updates a static table
and swallows the store's answer -- so a write that failed left exactly
the same empty value as a write that never happened. Records beside an
empty baseline were read as an erasure that never finished and erased,
which on a first launch whose baseline write had merely failed deleted
a perfectly good invite: a direct link or an App Clip persists a
pending record, the process exits, and the next launch destroys it.
The baseline is a checked InviteStore record now, so its absence means
the write never landed rather than that an identity changed, and
nothing is erased on the strength of it. A real reset is still caught
by the baseline differing, and by the durable erasure marker.

The second is why build-test (8) was red. postResponse() read the
payload field twice -- once to apply the slug, again to decide whether
the answer acknowledged the queued registration -- and the field is not
stable across the method: ConnectionRequest re-reads it on a redirect,
and the simulator's transport re-delivers a queued request whenever the
EDT yields underneath us. The second read then saw an empty body, the
registration was never retired, and it would have gone out on every
later flush for ever. Read into a local, once.

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: 84486701f4

ℹ️ 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 Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java Outdated
Two review findings, both about a value invented where none was known.

The pre-22 chooser branch called the listener with sharedTo(null) before
startActivity ran. Nothing afterwards can report the outcome --
EXTRA_CHOSEN_COMPONENT arrives with API 22, and a chooser started for a
result answers RESULT_CANCELED either way -- so that was a guess, in the
direction that costs money: reportShareResult() emits invite_shared on
it, and an application's reward logic saw a successful share for a sheet
the user swiped away. FAILED carries the one thing actually known, the
outcome was not observed; the funnel emits nothing for it and the
callback still completes, which is what the branch was added for. The
exception fallback did the same thing and is fixed with it.

invite.slug was passed through unvalidated into /i/<slug>/<code> in
three consumers. A reserved delimiter is not merely unsafe there but
unreachable: acme?preview makes the code parse as query text while the
intent filter waits for the literal path /i/acme?preview/. The service
allocates slugs from lowercase letters, digits and '-' in a 32 character
column, so anything outside that set cannot name a slug it would issue.
The build is refused rather than warned -- nothing has shipped with this
hint, and the alternative is a link that looks right in the manifest and
can never work in the field.

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