Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 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
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
66 changes: 66 additions & 0 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 @@ -147,8 +148,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 @@ -463,6 +490,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 +505,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 +527,29 @@ public static String resetClientId() {
return clientId;
}

/// 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 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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* 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;

/// Reads the invite code an iOS App Clip left behind for the full
/// application.
///
/// This is the iOS half of deterministic attribution, and the counterpart of
/// [InstallReferrerSource] on Android. An App Clip is launched by the invite
/// link itself and receives that link exactly, so it can write the code into
/// the container it shares with the full application before offering the App
/// Store. When the person installs, the application reads it here: the code
/// made the whole trip through the store, so nothing is matched or guessed.
///
/// It replaced a statistical match against a hashed device profile, which
/// existed only because the App Store carries no referrer of its own. Nothing
/// about the visitor is collected any more.
///
/// The Codename One build supplies the implementation on platforms that have
/// one and registers it through [Invites#registerAppClipHandoffSource] before
/// the application starts. Where none is registered -- the simulator, the
/// desktop build, Android, and any iOS application built without an App Clip
/// -- [Invites] behaves exactly as it does when a clip left nothing.
///
/// An application does not implement this interface.
public interface AppClipHandoffSource {
/// Whether this source can answer at all on the current device.
///
/// #### Returns
///
/// true when a shared container is reachable
boolean isSupported();

/// Asks for the code an App Clip left behind. The answer arrives on the
/// callback, possibly asynchronously and possibly on another thread;
/// [Invites] marshals it back onto the EDT.
///
/// The handoff is read once and cleared by the implementation, so a code
/// cannot be claimed twice by two launches.
///
/// #### Parameters
///
/// - `callback`: receives the answer, never null
void requestHandoff(AppClipHandoffCallback callback);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* 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 from an [InstallReferrerSource].
///
/// Implemented by the framework; an application never implements this.
public interface InstallReferrerCallback {
/// Called with the raw referrer query string the store recorded at
/// install time.
///
/// #### Parameters
///
/// - `rawReferrer`: the undecoded referrer query string, may be empty
///
/// - `referrerClickSeconds`: when the link was clicked, in seconds since
/// the epoch, or 0 when the store did not say
///
/// - `installBeginSeconds`: when the install began, in seconds since the
/// epoch, or 0 when the store did not say
void onReferrer(String rawReferrer, long referrerClickSeconds,
long installBeginSeconds);

/// Called when no referrer can be obtained. This is the normal answer on
/// a device with no store client -- a sideload, an emulator without store
/// services, or a non-store distribution -- and is not an error.
///
/// #### Parameters
///
/// - `reason`: one of the `REASON_` constants on [Invites]
void onUnavailable(String reason);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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;

/// Reads the referrer the application store recorded when this application
/// was installed. This is the deterministic half of invite attribution: the
/// invite code makes the whole round trip through the store, so no matching
/// or guessing is involved.
///
/// The Codename One build supplies the implementation on platforms that have
/// one and registers it through
/// [Invites#registerInstallReferrerSource] before the application starts.
/// Where none is registered -- the simulator, the desktop build, iOS, and any
/// Android device without store services -- [Invites] behaves exactly as it
/// does on a device that reports no referrer.
///
/// An application does not implement this interface.
public interface InstallReferrerSource {
/// Whether this source can answer at all on the current device.
///
/// #### Returns
///
/// true when a store client is present
boolean isSupported();

/// Asks for the install referrer. The answer arrives on the callback,
/// possibly asynchronously and possibly on another thread; [Invites]
/// marshals it back onto the EDT.
///
/// #### Parameters
///
/// - `callback`: receives the answer, never null
void requestReferrer(InstallReferrerCallback callback);
}
Loading
Loading