Invite a friend: referral attribution across the analytics stack - #5751
Invite a friend: referral attribution across the analytics stack#5751shai-almog wants to merge 105 commits into
Conversation
There was a problem hiding this comment.
💡 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".
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Developer Guide build artifacts are available for download from this workflow run:
Developer Guide quality checks: |
|
Compared 12 screenshots: 12 matched. |
Cloudflare Preview
|
|
Compared 151 screenshots: 151 matched. Native Android coverage
✅ Native Android screenshot tests passed. Native Android coverage
Benchmark ResultsDetailed Performance Metrics
|
✅ Continuous Quality ReportTest & Coverage
Static Analysis
Generated automatically by the PR CI workflow. |
Adds com.codename1.analytics.invite: mint an invite link, share it through the native share sheet, and on the invited device recover the invite that caused the install. Resolved attribution is written as persistent analytics dimensions, so every later event -- including the purchase event the framework already emits -- carries the campaign and the referrer. The package boundary is load-bearing, not cosmetic. The PlatformFeatureCatalog entry that buys the Play Install Referrer library also raises the application's minimum API level to 21, and the catalog matches on a package prefix. Keyed one package higher it would match com/codename1/analytics/Analytics, which nearly every application references, and put that dependency and that floor on all of them -- the DatabaseConfig failure AndroidGradleBuilder.usesClass records, which deleting the unused sources later does not undo. Two tests pin the boundary and were confirmed to fail when the prefix is widened. Analytics.java is not modified. resetClientId() does not clear custom dimensions, which is right for an application's own dimensions but would leave the referral dimensions behind and re-link a fresh pseudonymous id to the same inviter. InviteAttributionProvider observes the client id through the init callback Analytics already makes, and erases only the referral dimensions.
Adds 33 unit tests over the invite client: minting offline, url and referrer parsing, the funnel events, the consent state machine, erasure, and exactly-once delivery. Three of them are the ones worth keeping honest about: - resetClientId must clear the referral dimensions AND leave the application's own dimensions alone. Both halves are asserted, because either one alone is a bug. - Opt-out consent mode alone must not authorise the statistical match. The deprecated AnalyticsService forces that mode, so the ordinary gate reports permission with no user choice on record. - A dismissed share sheet must never report invite_shared, which is what makes the shared count a measurement rather than an assumption. The referrer key is compared with equals and never case folded, and a test pins that a differently cased key does not match: String.toLowerCase is locale sensitive with no root-locale overload in this runtime, so a folded comparison silently stops matching under a Turkish default locale. Ten SpotBugs findings and four cast-semantics findings in the new code are fixed rather than excluded. The one exclusion added is scoped to Invites$InviteConnection, a one-shot ConnectionRequest that is never compared or used as a map key -- the same idiom and reasoning as the existing OsrmRouteService$RouteConnection entry. The casts were rewritten into the positive instanceof form the verifier recognises, which matters beyond the gate: ParparVM does not throw on a failed cast, so the surrounding catch(Throwable) would never have run on iOS.
Three hints, all in the catalog rather than on the annotations, so each stays beside the hint it composes with -- ios.associatedDomains and android.xintent_filter are both catalog-only today, and splitting one feature's hints across two declaration files is how they drift. invite.domain is deliberately platform-general: both builders read it, and the link service it names has to agree with the apple-app-site-association and assetlinks.json served from that host. There is no hint to turn invites on. The class scan is the switch, through the PlatformFeatureCatalog entry -- a second source of truth for the same fact is a second thing to keep in sync. android.invite.signingFingerprint carries the Play App Signing warning in its own doc text because the failure has no other surface: Google re-signs the app, so verifying against the upload key the build holds means autoVerify fails on every Play install, the link opens Chrome, and nothing reports an error.
Both builders now detect com.codename1.analytics.invite (and the InviteButton that fronts it) in the class scan, and wire the platform side. Android gets an autoVerify App Links intent filter, appended to android.xintent_filter rather than emitted at a new manifest site. That hint is already rendered inside the main <activity>, and rendered a second time into the wear companion manifest, so one append reaches both and cannot drift the way two injection sites would. It is the repo's first use of autoVerify. The build is REFUSED when android.activity.launchMode is "standard", rather than warned. With singleTop (the default) or singleTask a link reaches the running activity through onNewIntent; with standard it starts a second activity and the invite is simply lost. A warning in a build log is the thing nobody reads, and the symptom on the device is a feature that silently never fires. iOS appends applinks:<host> to ios.associatedDomains. The placement is load-bearing and commented as such: the block that uncomments CN1_HANDLE_UNIVERSAL_LINKS tests only whether that hint is non-null, so appending one line later would leave the define commented out -- entitlement present, handler not compiled in, every link opening Safari. The matching associated-domains entitlement is derived from the same hint downstream, so it is deliberately not written separately: a duplicate key fails codesigning. Both duplicate-suppression checks compare whole delimited tokens rather than substrings, because the failure is asymmetric and silent -- a developer's staging entry for a longer host would otherwise read as declaring the production one, and they would ship an app whose invite links open the browser. Thirteen tests cover it, and the staging case was confirmed to fail against a naive contains() implementation.
…that was never there Adds the deterministic Android path. The link service puts cn1_invite=<code> on the Play url, the store hands it back on first launch, and the code is claimed verbatim -- no matching, no guessing. The implementation is a port source excluded from the port jar's compile and compiled inside the generated app, the mechanism ar/ai/cipher/nearby already use, with the builder deleting the package for apps that did not ask. Deliberately not a generated string literal like the Firebase bridge: this owns a connection lifecycle, a reconnect path, a bounded retry and once-only bookkeeping, and as a literal it would be invisible to review and to SpotBugs. Registration is spliced beside the Firebase one as a direct symbol reference, so R8 renames call site and target together and there is no keep rule to forget. FEATURE_NOT_SUPPORTED -- no Play Store, a sideload, another vendor's store -- is surfaced as an ordinary "no referral" answer, not an error and not silence. The correction: the plan asserted this dependency carries a minSdk 21 floor, and it does not. Reading the actual artifact rather than trusting the assumption, installreferrer 2.2 (the newest release) declares minSdkVersion 8 in its own manifest. The catalog entry now sets no floor, because adding one would have dropped API 19 and 20 devices from every invite app's Play listing for no reason. The aar also contributes its own BIND_GET_INSTALL_REFERRER_SERVICE permission, so none is declared here. The package boundary still earns its keep -- it keeps the dependency and that permission off every app that merely reports analytics.
A new Analytics chapter section covering sending, receiving, closing the funnel, and the build wiring, with three compilable snippets. Two things it says plainly rather than glossing: The three match types are not equally trustworthy, and the section says which is which. MATCH_DIRECT and MATCH_REFERRER are exact; MATCH_FINGERPRINT is a statistical match, used because the App Store carries no referrer parameter of its own, and it is occasionally wrong. The advice is to report it as an estimate and not to pay a referral bounty on it without saying so. A coarse device profile is written to local storage on first launch, before consent, so a deferred match is still possible if consent arrives in time. The section says so, says it is never transmitted while consent is withheld and is deleted if consent is refused, and says why there is no alternative that also works -- the match window closes long before a consent prompt is answered. The Play App Signing warning is repeated here because that failure has no other surface: verification runs against the certificate the installed APK is signed with, which under Play App Signing is Google's key rather than the upload key, and getting it wrong means every invite link opens the browser with nothing reporting an error. Vale, paragraph capitalization, guide structure, xrefs, code blocks and snippet validation all pass, and the snippets compile.
"Send App Argument" already covers the installed-app half -- paste an invite link into it. What it cannot reach is the deferred half, which is the one most likely to ship broken: the install-referrer parser is otherwise exercised only by a real Play install, on a real device, once. The menu feeds the parser the exact string the link service puts on the Play url, so what runs is the production path rather than a stand-in. "Clear Invite Attribution State" exists because attribution is deliberately once-per-install. Without it a developer can test the first-launch path exactly once per machine, which is precisely how once-only bugs reach production. Added to BOTH simulateMenu assembly sites. The menu is built in one place and rebuilt from scratch in another, so an item added to only one of them silently does not exist on the other path.
The two ports were asymmetric here, and silently so.
iOS routes every deep link through Display.setProperty("AppArg", url), which
fires Navigation.dispatchExternalUrl. Android's onNewIntent only stored the
intent, and getAppArg() then derived the value lazily through the
implementation's own setAppArg -- so setProperty never ran and the router never
fired. Anything built on @route therefore worked on iOS and did nothing on
Android. That does not surface as a bug report; it surfaces as a feature that
"just doesn't convert" on one platform.
Deliberately narrow: only ACTION_VIEW with an http or https scheme goes through
the new path. EXTRA_TEXT shares, content:// attachments and EXTRA_STREAM
payloads keep their existing lazy route. Dispatching for every intent would
double-fire against the setAppArg inside getAppArg and change behaviour for
every share-target application already in the field.
Invite attribution does not depend on this -- Invites.checkForInvite reads the
launch argument directly, which is the one path that behaves the same on both
ports, and it was written that way BECAUSE of this asymmetry. This fixes the
asymmetry itself, for everything else built on the router.
|
Compared 181 screenshots: 181 matched. |
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.
d9eec65 to
47a8945
Compare
There was a problem hiding this comment.
💡 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".
|
Compared 148 screenshots: 148 matched. Benchmark Results
Detailed Performance Metrics
|
…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.
There was a problem hiding this comment.
💡 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".
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.
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
…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>
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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>
There was a problem hiding this comment.
💡 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".
…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>
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
…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>
There was a problem hiding this comment.
💡 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".
…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>
💡 Codex ReviewWhen another installed app sends the predictable If an application starts a second share before the first chooser finishes—for example from two ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
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>
There was a problem hiding this comment.
💡 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".
…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>
There was a problem hiding this comment.
💡 Codex Review
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".
…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>
There was a problem hiding this comment.
💡 Codex Review
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>
There was a problem hiding this comment.
💡 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".
… 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>
There was a problem hiding this comment.
💡 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".
…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>
There was a problem hiding this comment.
💡 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".
…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>
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
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>
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
purchaseevent 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.invite—Invites,InviteRequest,Invite,InviteAttribution,InviteListener, the install-referrer SPI, andInviteButtonbesideShareButton.autoVerify) and the Play Install Referrer; iOS associateddomains.
PlatformFeatureCatalogentry, a developer-guidesection, and a simulator menu for the deferred path.
Things worth a reviewer's attention
Analytics.javais not modified.resetClientId()deliberately does notclear 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),InviteAttributionProviderobserves the client id through theinitcallbackAnalyticsalready makes and erases only thecn1_*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
DatabaseConfigfailureAndroidGradleBuilder.usesClassrecords. Two tests pin the boundary and were confirmed to fail when the prefix
is widened.
A floor that did not exist. The plan assumed
installreferrercarries aminSdk 21floor. Reading the actual AAR, 2.2 declaresminSdkVersion 8, so nofloor is set — adding one would have dropped API 19–20 devices for nothing.
Two match types are exact and one is not.
MATCH_DIRECTandMATCH_REFERRERare exact.MATCH_FINGERPRINTis a statistical match madeserver-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.
9a70c18repairs a cast-semantics baseline failurethat exists on
masterindependently of this work — #5746 renumbered ananonymous class in
AndroidImplementationfrom$46to$47. Happy to splitit out.
Verification
core-unittests verifyBUILD SUCCESSverifyBUILD SUCCESS///docs, no-@since, package-info, control characters,cast semantics, build-hint catalog (ratchet still empty) — all green
green for the new guide section
🤖 Generated with Claude Code