Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
105 commits
Select commit Hold shift + click to select a range
e2b4b11
Invite attribution: core client API, catalog entry and package guard
shai-almog Sep 9, 2026
1a5055e
Invite attribution: tests, and the analysis gates they have to pass
shai-almog Sep 9, 2026
5b44d6b
Invite attribution: declare the build hints the link plumbing will read
shai-almog Sep 9, 2026
8f64c92
Invite attribution: make an invite link open the app, on both platforms
shai-almog Sep 9, 2026
76b058b
Invite attribution: read the Play Install Referrer, and drop a floor …
shai-almog Sep 9, 2026
95e69cb
Invite attribution: document it in the developer guide
shai-almog Sep 9, 2026
63b514b
Invite attribution: simulate the deferred path without a device
shai-almog Sep 9, 2026
5c6d14f
Android: deliver a link that reaches a running activity to the router
shai-almog Sep 9, 2026
47a8945
Invite attribution: address the review, and clear the PMD gate
shai-almog Sep 9, 2026
3c43f5c
Invite attribution: second review round, and a domain the client neve…
shai-almog Sep 9, 2026
e63dfed
Invite attribution: third review round on the client
shai-almog Sep 9, 2026
bfe5429
Invites: six ways attribution used to be lost quietly
shai-almog Sep 9, 2026
5610439
Invites: four more ways a terminal answer was reached too early
shai-almog Sep 9, 2026
57fc4c3
Invites: a direct link now wins, and a pending answer is not a termin…
shai-almog Sep 9, 2026
43b57ce
Android port: exclude the referrer package from the Ant build too
shai-almog Sep 9, 2026
5a71e81
Invites: six answers that reached nobody, or reached the wrong conclu…
shai-almog Sep 9, 2026
37706e6
Invites: an erasure that does not depend on who is registered
shai-almog Sep 9, 2026
81c7aa8
Invites: five consequences of the last two rounds
shai-almog Sep 9, 2026
1546075
Invites: re-attribution no longer contradicts the answer it already gave
shai-almog Sep 9, 2026
7a767ca
Invites: three more that the last two rounds' fixes opened
shai-almog Sep 9, 2026
abfb082
Invites: provenance, abandonment, and a fragment that became part of …
shai-almog Sep 9, 2026
cdb508d
Invites: a direct link is its own question, and a reopened one keeps …
shai-almog Sep 9, 2026
8bfb8eb
Invites: four places the state machine forgot what it already knew
shai-almog Sep 9, 2026
9dbd595
Invites: the window bounds the guess, not the answer
shai-almog Sep 9, 2026
8e45b87
Invites: one delivery is not one url, and a failed write is not a res…
shai-almog Sep 9, 2026
9324a40
Invites: consuming the argument, and three restarts that should not h…
shai-almog Sep 9, 2026
e7c14df
Invites: an erasure has to reach the durable records, not just the di…
shai-almog Sep 9, 2026
b3667c2
Invites: the reopened marker is converted, not rebuilt
shai-almog Sep 9, 2026
abb2a1b
Invites: three more places a write's result was assumed
shai-almog Sep 10, 2026
883f9aa
Invites: reopen with a usable window and a real device profile
shai-almog Sep 10, 2026
242bad2
Invites: a failed pending write no longer loses what it was writing
shai-almog Sep 10, 2026
1dcfc6b
Invites: two ways a registration said yes when the answer was no
shai-almog Sep 10, 2026
9fe42d0
Invites: reconcile the cached state when a held record finally lands
shai-almog Sep 10, 2026
3c16d23
Invites: getState() never answers from a cache the record contradicts
shai-almog Sep 10, 2026
2afe6d2
Invites: four review findings, one of them a design decision reversed
shai-almog Sep 10, 2026
3b1d4c0
Invites: an erasure that lasts, a kill switch that reaches the wire
shai-almog Sep 10, 2026
0d0e067
Invites: the kill switch is about the guess, not about being deferred
shai-almog Sep 10, 2026
7c75853
Invites: an erasure that survives a failed write, and three smaller h…
shai-almog Sep 10, 2026
e629d9e
Invites: an erasure that is checked, and an answer that can arrive to…
shai-almog Sep 10, 2026
3330a86
Invites: the erasure has to take the outbox, and a refusal has to settle
shai-almog Sep 11, 2026
25dffdd
Invites: App Clips replace the statistical match on iOS
shai-almog Sep 11, 2026
018119b
Invites: five holes the App Clip path and the erasure left open
shai-almog Sep 11, 2026
3ef1b8c
Invites: the App Clip is generated now, not just entitled
shai-almog Sep 11, 2026
0132106
Invites: a transient store failure is not an answer, and the clip is …
shai-almog Sep 11, 2026
e18bff5
Invites: the retry could not deliver, and the app group was comma-joined
shai-almog Sep 11, 2026
d238328
Invites: a reset that could not finish, and a tap time nobody kept
shai-almog Sep 11, 2026
b935910
Analytics: an erasure that never reached the disk came back a launch …
shai-almog Sep 11, 2026
51173b4
Analytics: the header gate covers a file I modified
shai-almog Sep 11, 2026
e2d3dc6
Invites: the write check I added checked nothing, and two iOS build t…
shai-almog Sep 11, 2026
6d7a38f
Invites: a deletion tombstone read as pending, and a false privacy claim
shai-almog Sep 11, 2026
fb39ecf
Invites: a settled claim whose record refused to go was asked again
shai-almog Sep 11, 2026
7066438
Invites: the App Clip embed named the target, not the product
shai-almog Sep 11, 2026
4e39d4f
Invites: a burst of mints resent the whole queue, and a hint too much
shai-almog Sep 11, 2026
23ef7ab
Invites: three erasures that reported success and left something behind
shai-almog Sep 11, 2026
cd49ddd
Invites: an app with no invites would not have linked
shai-almog Sep 11, 2026
a4fb342
Invites: the generated App Clip had no icon, so the archive could not…
shai-almog Sep 11, 2026
801bfed
Invites: a tap time the refusal dropped, and a budget charged twice
shai-almog Sep 11, 2026
aa67da3
Invites: an erasure that died with the process, and a mark nothing re…
shai-almog Sep 11, 2026
c67db7d
Invites: warm deep links keep their intent, and an upgrade keeps its …
shai-almog Sep 11, 2026
b19ff95
Invites: a "not yet" answer is asked again in the same process
shai-almog Sep 11, 2026
44b972c
Invites: the durable attribution is the authority for its dimensions too
shai-almog Sep 11, 2026
fde7e27
Invites: an erasure reaches the registration already on the wire
shai-almog Sep 11, 2026
eb21b8b
Invites: one save for the dimensions, and a bounded set of queued reg…
shai-almog Sep 11, 2026
1802b96
Invites: the App Clip was never built on the default path, and four more
shai-almog Sep 11, 2026
4c3c0ba
Invites: consent withdrawal cancels what is already queued
shai-almog Sep 11, 2026
8675993
Invites: a queued claim is an erasure's problem too, and codes are co…
shai-almog Sep 11, 2026
3c2be9e
Invites: why the App Clip overlay takes no app identifier
shai-almog Sep 11, 2026
b57d51e
Invites: the restored-install detection clears its flag in one save
shai-almog Sep 11, 2026
385750c
Invites: an unanswered prompt stops the queue without settling anything
shai-almog Sep 11, 2026
820a655
Invites: the App Links filter and the minted url agree about the slug
shai-almog Sep 11, 2026
046511d
Invites: the clip keeps its code until the framework has one that sur…
shai-almog Sep 11, 2026
8219b71
Invites: a double tap on the invite button minted two codes
shai-almog Sep 11, 2026
869e508
Invites: getInvite() went null exactly when the app needed it
shai-almog Sep 11, 2026
7aa520a
Invites: a second press shared twice, and a retried write told nobody
shai-almog Sep 11, 2026
937bdda
Invites: a killed lookup still looked outstanding, and a cleared hand…
shai-almog Sep 11, 2026
7781cc3
Invites: the code now proves who minted it, and forgetting reaches th…
shai-almog Sep 11, 2026
7aff67e
Invites: the share guard would have killed the button on Android
shai-almog Sep 11, 2026
8985e5b
Invites: bound what reaches the outbox, and document what was promised
shai-almog Sep 11, 2026
5b4d745
Invites: an erasure that could not empty the clip reported success
shai-almog Sep 11, 2026
ca0d989
Invites: the App Clip handoff docs failed the developer guide prose gate
shai-almog Sep 11, 2026
42ddf86
Invites: the erasure check I added proved nothing, and a useless answ…
shai-almog Sep 11, 2026
ee083dd
Invites: a handoff-only erasure failure latched nothing
shai-almog Sep 11, 2026
3a53d48
Invites: a foreign link could claim the install, and Play's one shot …
shai-almog Sep 11, 2026
d80a4bd
Invites: the simulator's referrer source broke the build
shai-almog Sep 11, 2026
2cda78a
Invites: a failed acknowledgement was treated as done
shai-almog Sep 11, 2026
5d0e166
Invites: the proof had a fallback that made it forgeable
shai-almog Sep 12, 2026
81743c8
Invites: an app group list could end up mixing its delimiters
shai-almog Sep 12, 2026
13c4a83
Invites: a routed link was handled twice, and a link base could be un…
shai-almog Sep 12, 2026
d597eec
Invites: consuming the argument too early could lose a tapped invite
shai-almog Sep 12, 2026
05fa40d
Invites: a domain hint written as a URL reached the platform metadata…
shai-almog Sep 12, 2026
08fd5e5
Invites: two fixes that were applied to instances instead of to the c…
shai-almog Sep 12, 2026
419fd80
Invites: forgetting has to reach the Play referrer too
shai-almog Sep 12, 2026
9c1a9e4
Invites: the registration carries the time the device minted the code
shai-almog Sep 12, 2026
ffce0c8
Install the erasure hook at source registration, and stop leaking a r…
shai-almog Sep 12, 2026
22fcf2d
Clear the owed-erasure marker on a successful reset, and claim a rout…
shai-almog Sep 12, 2026
caafc53
Restore AndroidImplementation's CRLF line endings
shai-almog Sep 12, 2026
271f17b
Merge master into invite-attribution
shai-almog Sep 12, 2026
20ba08c
Do not learn a slug from a link, require the code to look like one, a…
shai-almog Sep 12, 2026
c30f5a9
Validate query-borne codes as well, and mint afresh when the request …
shai-almog Sep 12, 2026
0e7d89e
Give each chooser its own PendingIntent, and make the share token ung…
shai-almog Sep 12, 2026
f130fe2
Reject userinfo in an invite URL, and retire a registration only on a…
shai-almog Sep 12, 2026
a3f5b61
Complete the share listener on pre-22 Android, and hold the clip to t…
shai-almog Sep 12, 2026
79107df
Stamp the installation when discarding a referrer, and read the core'…
shai-almog Sep 12, 2026
8448670
Make the identity baseline durable, and read the response body once
shai-almog Sep 12, 2026
3b0fa87
Stop claiming a share nobody measured, and refuse an unusable slug
shai-almog Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 191 additions & 7 deletions CodenameOne/src/com/codename1/analytics/Analytics.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.codename1.ui.Display;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
Expand Down Expand Up @@ -70,6 +71,17 @@ public final class Analytics {
private static final String PREF_CONSENT_AD = "cn1$analyticsConsentAdStorage";
private static final String PREF_DIMENSIONS = "cn1$analyticsDimensions";

// The client id the persisted dimensions were written under.
//
// Preferences.set discards the write-failure boolean, so an erasure that
// could not reach the disk removed the reserved dimensions from memory and
// left them in the file: the next launch loaded them back and attached the
// erased referral identity to the NEW client id, which is the one thing
// resetClientId() exists to prevent. Verifying the write closes that
// inside the process; this closes it across a restart, where no in-memory
// retry survives to run.
private static final String PREF_DIMENSIONS_OWNER = "cn1$analyticsDimensionsOwner";

private static final Object LOCK = new Object();
private static final List<AnalyticsProvider> PROVIDERS = new ArrayList<AnalyticsProvider>();
// App-scoped segmentation dimensions ("plan", "role", ...) that the cloud
Expand Down Expand Up @@ -147,8 +159,34 @@ public static void setConsentMode(ConsentMode mode) {
if (mode == null) {
return;
}
List<AnalyticsProvider> snapshot;
synchronized (LOCK) {
if (mode == consentMode) {
return;
}
consentMode = mode;
snapshot = new ArrayList<AnalyticsProvider>(PROVIDERS);
}
// Providers are told, because the mode decides what an absent choice
// means: under OPT_IN nothing is permitted until the user answers, and
// under OPT_OUT everything is until they refuse. Changing it therefore
// changes what is allowed for a user who has answered nothing, and
// without this dispatch ordinary events resumed while a feature that
// had stopped on the old mode stayed stopped -- the two disagreeing
// about the same user with nothing to reconcile them.
//
// The consent handed over is the effective one, exactly as
// setConsent() does, so a provider needs no second rule for this path.
AnalyticsConsent recorded = getConsent();
AnalyticsConsent effective = recorded != null ? recorded
: (mode == ConsentMode.OPT_OUT
? AnalyticsConsent.granted() : AnalyticsConsent.denied());
for (AnalyticsProvider p : snapshot) {
try {
p.onConsentChanged(effective);
} catch (Throwable t) {
Log.e(t);
}
}
}

Expand Down Expand Up @@ -333,6 +371,12 @@ public static void setUserProperty(String key, String value) {
/// with every first-party batch. Passing a null value removes the key.
/// Null or empty keys are ignored.
///
/// The `cn1_` prefix is RESERVED for dimensions the framework writes on
/// your behalf, and those are cleared by [#resetClientId] because they
/// identify the user across installs. A key of your own under that prefix
/// is accepted -- it always was -- but it will be erased along with them,
/// so pick another one.
///
/// #### Parameters
///
/// - `key`: the dimension key
Expand Down Expand Up @@ -463,6 +507,13 @@ public static String clientId() {
/// every provider with the new identity. Use this to honour a "right to be
/// forgotten" / erasure request from the user.
///
/// Custom dimensions your application set are kept -- a `plan` or `role`
/// dimension describes the app, not the person, and losing it silently on
/// an erasure would surprise you. Dimensions under the reserved `cn1_`
/// prefix are cleared, because those are written for you by framework
/// features that identify the user across installs, and carrying them onto
/// a fresh id would re-link the two.
///
/// #### Returns
///
/// the new client id
Expand All @@ -471,6 +522,15 @@ public static String resetClientId() {
synchronized (LOCK) {
clientId = newClientId();
Preferences.set(PREF_CLIENT_ID, clientId);
// Cleared here rather than left to whichever feature wrote them.
// The feature's provider is the ordinary route and does more --
// it drops its own 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 reserved 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.
clearReservedDimensions();
snapshot = new ArrayList<AnalyticsProvider>(PROVIDERS);
Comment thread
shai-almog marked this conversation as resolved.
}
AnalyticsContext ctx = context();
Expand All @@ -484,6 +544,44 @@ public static String resetClientId() {
return clientId;
}

// Package private test seam: makes the store look the way it does after an
// erasure whose write never landed -- the reserved dimensions still in the
// file, stamped with the identity that has since been reset -- and drops
// the in-memory copy so the next read comes off the disk, which is what the
// next process would do. There is no other way to produce a failed
// Preferences write from a test.
static void simulateSurvivingDimensionsForTest(String raw, String owner) {
synchronized (LOCK) {
Preferences.set(PREF_DIMENSIONS, raw);
Preferences.set(PREF_DIMENSIONS_OWNER, owner);
DIMENSIONS.clear();
dimensionsLoaded = false;
}
}

/// The prefix reserved for dimensions the framework writes on your behalf.
/// Do not use it for your own dimensions: everything under it is cleared by
/// [#resetClientId].
public static final String RESERVED_DIMENSION_PREFIX = "cn1_";

// Must be called while holding LOCK.
private static void clearReservedDimensions() {
loadDimensions();
boolean changed = false;
Iterator<Map.Entry<String, String>> it = DIMENSIONS.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, String> e = it.next();
String key = e.getKey();
if (key != null && key.startsWith(RESERVED_DIMENSION_PREFIX)) {
it.remove();
changed = true;
}
}
if (changed) {
persistDimensions();
}
}

// Must be called while holding LOCK. Lazily loads the persisted dimensions
// from a tab/newline delimited string: rows are newline separated, key and
// value within a row are tab separated. Values had tabs/newlines replaced
Expand All @@ -497,6 +595,40 @@ private static void loadDimensions() {
if (stored == null || stored.length() == 0) {
return;
}
// Whose dimensions these are. An erasure that could not reach the disk
// leaves the reserved entries in the file under the PREVIOUS identity;
// loading them would attach the referral the user asked to be rid of
// to their new client id, one launch later and with nothing in memory
// left to notice.
//
// An ABSENT stamp is ADOPTED, not treated as foreign, and the reason
// is specific enough to be worth writing down -- the strict reading
// was tried first and destroyed live data.
//
// Dropping a reserved dimension is only ever right when the FRAMEWORK
// wrote it, and the framework cannot have written one into an
// unstamped file. Every write of this record goes through
// persistDimensions(), which stamps in the same call, and Preferences
// keeps both keys in one record, so a file written by a version that
// owns reserved dimensions always carries a stamp. An absent one means
// the file predates the feature -- and back then `setDimension`
// accepted every key, documented no reserved prefix, and never wrote a
// `cn1_` dimension itself. So anything with that prefix in an
// unstamped file is the APPLICATION's, and dropping it silently
// deletes analytics segmentation from an app that did nothing wrong
// and never asked for an erasure.
//
// The erasure case the stamp defends against still works, because it
// cannot produce this state: the identity reset happens on a version
// that stamps, so the surviving file carries the PREVIOUS id and
// compares unequal below.
//
// clientId() rather than the field, because loading can happen before
// the id has been materialised and a null would make every file look
// foreign. It does not read dimensions, so there is no recursion.
String owner = Preferences.get(PREF_DIMENSIONS_OWNER, null);
boolean unstamped = owner == null;
boolean foreign = !unstamped && !clientId().equals(owner);
String[] rows = split(stored, '\n');
for (String row : rows) {
if (row.length() == 0) {
Expand All @@ -508,18 +640,46 @@ private static void loadDimensions() {
}
String key = row.substring(0, tab);
String value = row.substring(tab + 1);
if (key.length() > 0) {
DIMENSIONS.put(key, value);
if (key.length() == 0) {
continue;
}
if (foreign && key.startsWith(RESERVED_DIMENSION_PREFIX)) {
Comment thread
shai-almog marked this conversation as resolved.
// The framework's own dimensions, belonging to an identity
// that has since been reset. Dropped rather than loaded: this
// is the erasure finishing late, and the alternative is
// handing the new client id the referral it was reset to
// forget.
//
// The APPLICATION's dimensions are kept. They are not what an
// erasure asked about, and losing a plan or role the app set
// would be a second bug in the name of fixing the first.
continue;
}
DIMENSIONS.put(key, value);
}
if (foreign || unstamped) {
// Rewritten under the current identity so the drop -- or, for an
// unstamped file, the one-time adoption -- happens once. If this
// write fails the next launch simply repeats it, which is the
// correct outcome either way.
persistDimensions();
}
}

// Must be called while holding LOCK.
/// Writes the dimensions and the identity they belong to.
///
/// There is deliberately NO read-back check here, and one was tried and
/// removed: `Preferences.set` updates a static table and `Preferences.get`
/// reads that same table, so reading a value back after writing it
/// compares memory with memory and reports success for a write that never
/// reached the disk. It looked like verification and verified nothing.
///
/// The erasure is made safe by the stamp instead, which needs no write to
/// succeed -- see [#loadDimensions]. Both keys live in the SAME
/// preferences record, so they land together or not at all; there is no
/// state where the dimensions survive under a stamp that disowns them.
private static void persistDimensions() {
if (DIMENSIONS.isEmpty()) {
Preferences.set(PREF_DIMENSIONS, "");
return;
}
StringBuilder b = new StringBuilder();
boolean first = true;
for (Map.Entry<String, String> e : DIMENSIONS.entrySet()) {
Expand All @@ -529,7 +689,31 @@ private static void persistDimensions() {
b.append(sanitize(e.getKey())).append('\t').append(sanitize(e.getValue()));
first = false;
}
Preferences.set(PREF_DIMENSIONS, b.toString());
// ONE save for both keys. Preferences.set(String, Object) calls save()
// per key, so the two used to be two serializations of the whole map
// with a window between them -- and a comment here claimed they landed
// together because they share a record, which was simply wrong.
//
// The batched form makes that true instead of assumed. It is worth
// being precise about what it does and does not fix, because the
// obvious story is not the real one: save() writes the ENTIRE map, so
// a failed first save followed by a successful second still persisted
// both new values -- the "old dimensions under a new owner" state is
// not reachable that way. What the window really allowed was the
// reverse, a save that landed followed by one that did not, leaving
// new dimensions under the PREVIOUS stamp. loadDimensions() reads that
// as foreign and drops them, which is conservative and correct, and
// reconcileDimensions() puts them back from the durable record. One
// save removes the window rather than the consequence.
//
// clientId() rather than the field: the field is null until something
// materialises the id, and stamping a placeholder would make the file
// read as foreign on the next launch and drop the dimensions this call
// was in the middle of saving.
Map<String, Object> record = new LinkedHashMap<String, Object>();
record.put(PREF_DIMENSIONS, b.toString());
record.put(PREF_DIMENSIONS_OWNER, clientId());
Preferences.set(record);
}

// Replaces the delimiter characters so the persisted form parses back
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.analytics.invite;

/// Receives the answer to [AppClipHandoffSource#requestHandoff].
///
/// Exactly one method is called, once.
public interface AppClipHandoffCallback {
/// Called with the invite code an App Clip recorded.
///
/// #### Parameters
///
/// - `code`: the invite code the clip received, never empty
///
/// - `clickedSeconds`: when the link was tapped, in seconds since the
/// epoch, or 0 when the clip did not record it
void onHandoff(String code, long clickedSeconds);

/// Called when no clip handoff exists. This is the normal answer for
/// somebody who installed the application without ever tapping an invite
/// link, and is not an error.
///
/// #### Parameters
///
/// - `reason`: one of the `REASON_` constants on [Invites]
void onUnavailable(String reason);
}
Loading
Loading