Invite a friend: referral attribution across the analytics stack - #5751
Invite a friend: referral attribution across the analytics stack#5751shai-almog wants to merge 28 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: 43b57ce484
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…sion flush() retried the deferred lookup under the same epoch, so a fingerprint answer left outstanding by the previous attempt could land after the retried referrer resolved exactly and overwrite it. Genuinely concurrent on an application with more than one NetworkManager thread. The retry advances the epoch, as handleUrl now does. Granting consent after a refusal left the lookup stopped. STATE_DECLINED carries a reopenable marker precisely because granting afterwards is a real answer, but onConsentChanged only restarted STATE_PENDING -- so nothing happened until the application happened to call checkForInvite() again, by which time the attribution window may have closed. A terminal "no invite" answer reached before a listener was registered was dropped. The state prevents another lookup and setInviteListener only replays a resolved attribution, so the listener got neither callback for the whole install -- against the documented promise that an early answer is held and delivered on registration. It is held for the run now; the state itself is durable, so a later launch reaches the same answer through the ordinary path. A direct link refused on consent told the listener nothing, and checkForInvite marks the url consumed and skips the deferred path afterwards, so that was the only chance it had. On Android, a getInstallReferrer() that throws after the connection came up -- a service-side RemoteException -- still burned the once-only attempted flag, so isSupported() was false for ever and a statistical no-match could settle the install as organic for a referrer that was there all along. A throwing read is transient and is no longer recorded as an attempt. The manifest filter check ignored the scheme, so an http-only filter on the invite host and path suppressed the generated https one and every invite link kept opening the browser. The scheme is required in the same intent-filter, and a filter naming no scheme covers nothing, which is what Android does. Two more PMD NonThreadSafeSingleton shapes avoided the same way as loadState: the field is read into a local before the branch. Not a lock -- this facade runs on the EDT. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a71e814db
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Analytics.resetClientId now clears dimensions under the reserved cn1_ prefix. The invite provider is the ordinary route and does more -- it drops the durable records too -- but a provider can be absent: Analytics.clearProviders() is public and the deprecated AnalyticsService.init() calls it. In that window an erasure left the referral dimensions attached to the new id, and the next provider the application registered transmitted them. An erasure cannot depend on who happens to be registered when it runs. Scoped to the reserved prefix rather than clearing everything, because an application's own plan or role dimension describes the app and not the person, and losing it silently on an erasure would be its own surprise. The prefix is named and documented on the method. The Android referrer code is persisted before the claim goes out. The source has already burned its once-only flag by the time the callback runs, so a claim that failed left the exact code nowhere but that callback, and the next flush() fell back to a statistical match for an answer that had been read exactly. The failed-outbox fallback is gated on consent. drainOutbox carries that guard and this path had none, so a storage failure was the one way an undecided or refused user's client id and invite metadata reached the server. The registration is lost instead, which is the correct trade: the link still attributes through the click, and only the campaign, channel and preview metadata go with it. The outbox-failure paths now have a test seam, because a full or read-only store cannot be produced from a test and those paths are the ones most worth pinning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Play referrer callback read lookupEpoch when it fired rather than when the read was issued, so an outstanding read inherited the epoch a direct link had just advanced, passed the guard, and could overwrite the direct attribution. Incrementing an epoch cannot invalidate a callback that does not remember which epoch it belongs to; it captures the epoch at issue now. flush() restarted the lookup on every call, spending an attempt with no failure observed -- and create() calls flush() unconditionally, so five invites minted in a row exhausted MAX_ATTEMPTS and the last one settled the install as terminal while its own answer was still on the wire. It restarts only once the previous attempt has aged out. Bounded by a timestamp rather than a flag cleared by a response, because these requests are fail-silent: a failure produces no callback at all, so a flag would never be cleared for exactly the request a retry exists for and flush() could wedge for the rest of the process. A refusal held for a listener that had not registered yet was not cleared when consent was granted and the lookup resumed, so an attribution that went on to resolve was reported to that listener as unavailable. The held answer lived only in a static field, and the contract says "exactly one of the two methods per install, and the answer is remembered". A resolved attribution has carried a durable delivered flag from the start; the unavailable answer had nothing, so an application whose deferred question was settled before it registered a listener, in a process that then exited, got neither callback for the life of the install. The terminal marker carries the reason and its own delivered flag now -- which also keeps the other half of the contract, since it is not delivered twice. Under ConsentMode.OPT_OUT a null recorded choice is the mode's implicit allow, not an unanswered prompt. Ignoring it meant clearing an explicit denial resumed ordinary analytics while a declined invite lookup stayed stopped and a resolved attribution's dimensions stayed cleared -- the two disagreeing about the same user. The mode is consulted when there is no recorded choice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 81c7aa83e2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A re-attribution claim that found nothing terminalized an install that already had an attribution. That contradicted the durable record -- which still says RESOLVED and puts the state back on the next launch -- and told the listener "no invite" as a second, opposite callback after it had already been given one. The earlier attribution stands. Replacing an attribution reset the durable delivered flag, so the replacement was delivered as a second inviteReceived(): immediately if the first had happened in an earlier process, on the next launch if it had happened in this one. Re-attribution rewrites the attribution, not the fact that the listener has already been told about this install. The flag is carried across. The expiry marker carried no reason, so once the process that reached it exited, a late listener was told the marker's default -- no_match -- rather than that the window had expired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 15460753d4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The Play referrer read did not count as a lookup in flight -- only claim() and requestMatch() set the timestamp -- so a flush() during the read, which create() issues unconditionally, treated it as stale, advanced the epoch, and the epoch guard added last round then discarded the exact answer when it arrived. Worse than an ordinary lost retry: the source has already burned its once-only flag by then, so the deterministic result is gone for good and a statistical guess takes its place. A re-attribution claim that found nothing stopped terminalizing the install last round, but returning was not enough: handleUrl had already written a PENDING record for the replacement, so the install stayed pending and every later flush and launch retried the failed replacement until the attempt cap reported unavailable -- with the durable attribution sitting beside it the whole time. The replacement attempt is dropped and the install goes back to resolved. Reopening a terminal marker deleted the record of whether the listener had already been told, so a refusal that had been delivered was followed by a resumed lookup whose attribution was written as undelivered, and inviteReceived() arrived as a second callback on the next launch. The fact rides the pending record across the reopen, and the attribution reads it from there when there is no earlier attribution to inherit from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7a767caae3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…the code A referrer claim that timed out is persisted and retried, and the retry hard-coded the direct-link metadata -- so the answer came back with isDeferred() false and was recorded as invite_opened rather than invite_install, corrupting the install funnel for exactly the deterministic results that persistence exists to save. The pending record carries the provenance now and the retry resends what it was. Abandoning a re-attribution replacement is now one helper used by every way of giving up on it. The server no-match learned to do it last round; the attempt cap and the window expiry did not, so they wrote a terminal marker the durable attribution contradicts and told the listener "no invite" after it had already been given one. A denied invite URL arriving for an install that was already attributed did the same thing from the other direction, because the consent guard ran before the resolved check. Those installs keep their answer. An empty but successful Play referrer read burns the source's once-only flag, so it is definitive -- but it reports the same reason a transient failure does, and the lookup stayed pending until the attempt budget ran out for an answer that had already arrived. Retryability is read from whether the source would try again, not from the reason alone. A URI fragment was never stripped, so an App Link arriving as /i/acme/ABC123#section claimed a code called "ABC123#section". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: abfb082689
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…its clock A direct link reused whatever window and attempt budget an older deferred lookup had left on the pending record. Opened after that lookup had expired, or after its retries were spent, the exact code was persisted and then marked expired by beginDeferred() before it was ever looked at -- so an answer we were holding was never sent. It gets its own window and a fresh budget. Reopening a terminal marker after consent restarted the attribution window from the moment of the grant, because the marker kept no timing. A user answering the prompt a week later would then have run a fresh fingerprint lookup and reported invite_install for an unrelated click. The marker carries firstLaunch and expiresAt across, and the resumed record restores them. Those two fields are deliberately not treated as profile data by the tests that assert a refused profile is deleted: they are clock readings, they describe no device, and the marker never leaves it. Keeping them is what prevents the mismatch above, so dropping them would cost privacy rather than protect it. That assertion now names the profile fields instead of counting them, which is how it came to be arguing against this. The fragment is stripped once, before either branch parses the url. Doing it on the path branch alone left the query branch -- which runs first -- claiming "ABC123#section" from a query-style link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cdb508df4f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
setAttributionWindow(0) turns off the deferred lookup -- the statistical one that needs a window to mean anything -- and it was also discarding an exact code we were already holding, reporting "unsupported" for an invite the user really did open. The kill switch no longer applies when there is a saved code. setReattribution did not invalidate the cached state, and loadState reads the pending record only when re-attribution is on. A process that cached STATE_RESOLVED before the setter ran therefore never looked at a durable replacement again -- and setInviteListener, which most applications call first, is enough to cache it. The terminal marker inherited the timings a reopen carried but not the delivery state, so a resumed lookup that then expired or found nothing told a listener in the next process a second time. The reopen protection covered a successful resolve and not this. A direct link reopening the lookup left the held terminal answer in place, so a listener registered after the link resolved was handed the stale unavailable result -- and deliveredThisRun then suppressed the correct one. The consent resume already cleared it; this path did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8bfb8ebecc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The attribution window bounds the DEFERRED lookup, and the expiry check ran before the saved-code branch -- so an exact code we were already holding was marked expired whenever the two coexist: a zero window, where handleUrl records an expiry of "now", or a first claim that failed and is retried after the window ran out. Same reasoning as the kill switch last round, applied to the other gate. Analytics.setConsentMode now dispatches onConsentChanged. The mode decides what an absent choice means, so changing it changes what is permitted for a user who has answered nothing -- and without a dispatch ordinary events resumed on the switch while a feature that had stopped under the old mode stayed stopped, the two disagreeing about the same user with nothing to reconcile them. It hands over the effective consent, exactly as setConsent does, so a provider needs no second rule for this path. The invite provider keeps its early return for an unanswered OPT_IN prompt. That distinction is load-bearing and I broke it in the first version of this change: "no choice under opt-in" is not a refusal, and reporting one deletes the profile captured on the first launch and moves to DECLINED for a user who refused nothing. An existing test caught it. dispatchNewIntentUrl now consumes the intent's data, as the lazy getAppArg() path already does. CodenameOneActivity.onStop() clears the app arg, so leaving the data on the intent meant the next read after a resume rebuilt the same url and an application handling AppArg in start() saw the deep link a second time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9dbd595775
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…olution The launch argument was deduplicated for the life of the install, so tapping the same link again -- which delivers the identical string -- was ignored for ever: the install lost its invite_opened re-engagement event, and under re-attribution the later open could never win. It is deduplicated for the run instead, which is what the guard was actually for: ignoring repeated reads of one delivery by an application that calls checkForInvite from more than one place. What made the durable guard necessary was Android handing the same launch intent back on a later start, and both paths that read it now consume the intent's data -- the lazy getAppArg() always did, and dispatchNewIntentUrl does as of the previous commit -- so a stale intent no longer reproduces the argument. writeAttribution set the resolved state even when the durable write failed. Everything after that write assumes the record is on disk: deliverPending() re-reads it and finds nothing, and flush() will not retry because the state says resolved, so a valid answer was neither delivered nor asked for again until the process restarted. It returns instead, leaving the pending record in place for the next flush or launch. The test for that needed a seam: a full or read-only store cannot be produced from a test, and the paths that only run when a write fails are the ones most worth pinning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8e45b87689
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ave happened Remembering the last argument still could not tell two deliveries of one url apart from two reads of one delivery, and a live process really does span both -- an Android onNewIntent after the app is backgrounded is the ordinary case. So the property is consumed instead: a later read sees nothing, and a genuine second delivery sets it again and is handled. Only an invite is consumed, so an application routing its own deep links finds its argument exactly as it arrived, and the read happens after Display.setProperty has already fired the external-url dispatch. A consent update with analytics still allowed -- changing only personalization or ad storage -- restarted the lookup, queueing a second whose answer was as valid as the first, so the funnel event fired twice and repeated updates spent the retry budget with nothing having failed. It restarts only when nothing is outstanding, as flush() already did. STATE_DECLINED is exempt from that check: the withdrawal that produced it discarded whatever was in flight, which is now said explicitly so a later grant resumes at once rather than waiting out a retry delay for a request nobody can act on. Withdrawing consent during a re-attribution replacement wrote a DECLINED marker instead of abandoning it, telling a registered listener "no invite" as a second contradictory callback for an install that is still attributed and goes back to resolved on the next launch. A failed attribution write gives the attempt back. Leaving the counter at the cap meant the next flush marked the install terminal instead of performing the retry the previous commit promised -- so the very last response, the one most likely to be the only one left, could never be stored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9324a403e9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…mensions Analytics.resetClientId clears the reserved dimensions itself, which needs no provider -- but the durable attribution and the registration outbox are ours, and only InviteAttributionProvider.init drops them. clearProviders() is public and the deprecated AnalyticsService.init() calls it, so an erasure really can run with the provider absent, after which getAttribution(), conversion() and flush() could read or transmit the old referral identity under the new client id. Every entry point that reads or transmits stored data now re-registers the provider first, which re-runs that hook. Analytics deliberately does not do it for us: a reference from com.codename1.analytics to the invite package would match the platform feature catalog's prefix and put a Play dependency and an API floor on every application that logs a single event -- the DatabaseConfig scar this design was shaped around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e7c14df15e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A refusal is reopenable, so everything the marker holds has to survive the reopening -- and rebuilding the pending record from scratch lost each thing in turn, one review round at a time: the original window, then the delivered flag, now the direct-link code. A user who denied consent when the link arrived and granted it afterwards had the exact claim replaced by a referrer read or a statistical match, which can miss or credit a different click. So the marker is converted in place: state back to PENDING, reason dropped, everything else untouched. Three carry-over fields go with the rebuild they existed to compensate for, and the next thing the marker learns to hold will survive a reopening without anyone having to remember to add it. markTerminal reports whether its write landed, and no caller commits or delivers until it has. An unchecked write meant a storage failure still set the in-memory state and told the listener -- so the same lookup and the same callback repeated after every restart, or the delivery flag landed on the old pending record and left the state at PENDING, leaving a settled lookup running again with no way to deliver its answer. The refusal path in handleUrl records the code before it writes the marker, because that branch runs before the pending record exists and there would otherwise be nothing to carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b3667c20d6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| Map<String, String> before = InviteStore.read(InviteStore.PENDING); | ||
| InviteStore.put(done, "firstLaunch", InviteStore.get(before, "firstLaunch", null)); | ||
| InviteStore.put(done, "expiresAt", InviteStore.get(before, "expiresAt", null)); |
There was a problem hiding this comment.
Initialize clocks for first-time denial markers
When the first checkForInvite() occurs after analytics consent was already denied, no PENDING record exists, so both values copied here are null. If consent is granted later, beginDeferred() converts this marker to pending and reads the missing expiresAt as zero, bypassing the attribution window and allowing an arbitrarily old install to run a fingerprint match. Fresh evidence beyond the prior reopening fix is this no-prior-record path; initialize the marker's clock fields when before is null.
Useful? React with 👍 / 👎.
| deliveredThisRun = true; | ||
| r.put("delivered", "true"); | ||
| InviteStore.write(InviteStore.ATTRIBUTION, r); |
There was a problem hiding this comment.
Persist delivery before invoking the listener
If storage is temporarily full or read-only when this write occurs, the callback is still invoked even though the durable record retains delivered=false; deliveredThisRun suppresses duplicates only until process exit, so the next launch invokes inviteReceived() again. Fresh evidence after the terminal-marker persistence fix is that this final delivery-state write remains unchecked; the analogous unavailable-callback path through markUnavailableDelivered() has the same problem.
Useful? React with 👍 / 👎.
| int spent = InviteStore.getInt(retry, "attempts", 0); | ||
| retry.put("attempts", String.valueOf(spent > 0 ? spent - 1 : 0)); | ||
| InviteStore.write(InviteStore.PENDING, retry); |
There was a problem hiding this comment.
Ensure the refunded attempt reaches durable storage
When the attribution write failed because the store is read-only rather than because of the one-shot test seam, this unchecked write fails for the same reason, leaving the durable attempt count at MAX_ATTEMPTS. Once storage recovers, the next flush() reads that count and terminalizes the lookup instead of performing the promised retry. Fresh evidence beyond the earlier attempt-refund fix is that the refund's own persistence result is ignored.
Useful? React with 👍 / 👎.
|
Compared 144 screenshots: 144 matched. |
|
Compared 149 screenshots: 149 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
|
Compared 217 screenshots: 217 matched. |
|
Compared 160 screenshots: 160 matched. Benchmark Results
Detailed Performance Metrics
|
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