From e2b4b1179b3442da82968ed00cb920ac31b1abd2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:02:56 +0300 Subject: [PATCH 01/70] Invite attribution: core client API, catalog entry and package guard 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. --- .../invite/InstallReferrerCallback.java | 52 + .../invite/InstallReferrerSource.java | 54 + .../codename1/analytics/invite/Invite.java | 122 ++ .../analytics/invite/InviteAttribution.java | 168 +++ .../invite/InviteAttributionProvider.java | 89 ++ .../analytics/invite/InviteListener.java | 58 + .../analytics/invite/InviteRequest.java | 322 ++++ .../analytics/invite/InviteStore.java | 211 +++ .../codename1/analytics/invite/Invites.java | 1328 +++++++++++++++++ .../analytics/invite/package-info.java | 64 + .../codename1/components/InviteButton.java | 262 ++++ .../build/shared/PlatformFeatureCatalog.java | 29 + .../shared/PlatformFeatureCatalogTest.java | 54 + 13 files changed, 2813 insertions(+) create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InstallReferrerCallback.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/Invite.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InviteListener.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InviteStore.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/Invites.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/package-info.java create mode 100644 CodenameOne/src/com/codename1/components/InviteButton.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerCallback.java b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerCallback.java new file mode 100644 index 00000000000..61f6311a1a3 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerCallback.java @@ -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 + public 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] + public void onUnavailable(String reason); +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java new file mode 100644 index 00000000000..a8518f479e0 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java @@ -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 + public 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 + public void requestReferrer(InstallReferrerCallback callback); +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invite.java b/CodenameOne/src/com/codename1/analytics/invite/Invite.java new file mode 100644 index 00000000000..7f7198ec160 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/Invite.java @@ -0,0 +1,122 @@ +/* + * 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; + +/// An invite that has been minted and is ready to share. Immutable; create one +/// with [Invites#create]. +/// +/// [#getUrl] is the link to send. It is usable the moment [Invites#create] +/// returns, including with no network at all, so the share sheet never waits +/// on a server. +public final class Invite { + private final String code; + private final String url; + private final String campaign; + private final String channel; + private final String payload; + private final long createdTimestamp; + private final boolean registered; + + Invite(String code, String url, String campaign, String channel, String payload, + long createdTimestamp, boolean registered) { + this.code = code; + this.url = url; + this.campaign = campaign; + this.channel = channel; + this.payload = payload; + this.createdTimestamp = createdTimestamp; + this.registered = registered; + } + + /// The opaque invite code. This identifies the invite and authorizes + /// nothing, so it is safe to print, log or show to the user. + /// + /// #### Returns + /// + /// the code, never null + public String getCode() { + return code; + } + + /// The link to share. + /// + /// #### Returns + /// + /// an absolute https url, never null + public String getUrl() { + return url; + } + + /// The campaign this invite belongs to, or null. + /// + /// #### Returns + /// + /// the campaign + public String getCampaign() { + return campaign; + } + + /// The channel this invite was minted for, or null. + /// + /// #### Returns + /// + /// the channel + public String getChannel() { + return channel; + } + + /// The application defined payload carried to the invited device, or null. + /// + /// #### Returns + /// + /// the payload + public String getPayload() { + return payload; + } + + /// When this invite was minted, in milliseconds since the epoch. + /// + /// #### Returns + /// + /// the creation time + public long getCreatedTimestamp() { + return createdTimestamp; + } + + /// Whether the link service has acknowledged this invite. An invite that + /// has not been acknowledged is still shareable and still attributes -- + /// registration is retried in the background -- so this is a diagnostic, + /// not a gate. + /// + /// #### Returns + /// + /// true once the server has acknowledged the invite + public boolean isRegistered() { + return registered; + } + + @Override + public String toString() { + return "Invite[" + code + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java new file mode 100644 index 00000000000..1f9dce1eb64 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java @@ -0,0 +1,168 @@ +/* + * 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; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/// The invite that caused this install or open. Immutable; delivered to an +/// [InviteListener]. +/// +/// Read [#getMatchType] before acting on this. A deterministic match came +/// through the store or from a code the user entered and is exact. A +/// [Invites#MATCH_FINGERPRINT] match is a statistical guess made on the +/// server, because the App Store carries no referrer of its own, and it is +/// occasionally wrong. Do not pay a referral bounty on a probabilistic match +/// without saying so. +public final class InviteAttribution { + private final String code; + private final String campaign; + private final String channel; + private final String payload; + private final String matchType; + private final double confidence; + private final boolean deferred; + private final long clickTimestamp; + private final long resolvedTimestamp; + private final Map parameters; + + InviteAttribution(String code, String campaign, String channel, String payload, + String matchType, double confidence, boolean deferred, long clickTimestamp, + long resolvedTimestamp, Map parameters) { + this.code = code; + this.campaign = campaign; + this.channel = channel; + this.payload = payload; + this.matchType = matchType; + this.confidence = confidence; + this.deferred = deferred; + this.clickTimestamp = clickTimestamp; + this.resolvedTimestamp = resolvedTimestamp; + Map copy = new LinkedHashMap(); + if (parameters != null) { + copy.putAll(parameters); + } + this.parameters = Collections.unmodifiableMap(copy); + } + + /// The invite code that was matched. + /// + /// #### Returns + /// + /// the code, never null + public String getCode() { + return code; + } + + /// The campaign the invite belonged to, or null when the server could not + /// be reached to look it up. + /// + /// #### Returns + /// + /// the campaign + public String getCampaign() { + return campaign; + } + + /// The channel the invite was sent through, or null. + /// + /// #### Returns + /// + /// the channel + public String getChannel() { + return channel; + } + + /// The payload the inviter attached, or null. + /// + /// #### Returns + /// + /// the payload + public String getPayload() { + return payload; + } + + /// How this attribution was established: [Invites#MATCH_DIRECT], + /// [Invites#MATCH_REFERRER] or [Invites#MATCH_FINGERPRINT]. + /// + /// #### Returns + /// + /// the match type, never null + public String getMatchType() { + return matchType; + } + + /// How much to trust this attribution, from 0 to 1. Both deterministic + /// match types report 1; a fingerprint match reports the server's score. + /// + /// #### Returns + /// + /// the confidence + public double getConfidence() { + return confidence; + } + + /// Whether this attribution explains the install itself, as opposed to a + /// link opened by someone who already had the application. + /// + /// #### Returns + /// + /// true when the invite caused the install + public boolean isDeferred() { + return deferred; + } + + /// When the link was clicked, in milliseconds since the epoch, or 0 when + /// unknown. + /// + /// #### Returns + /// + /// the click time + public long getClickTimestamp() { + return clickTimestamp; + } + + /// When this attribution was resolved, in milliseconds since the epoch. + /// + /// #### Returns + /// + /// the resolution time + public long getResolvedTimestamp() { + return resolvedTimestamp; + } + + /// The custom parameters the inviter attached. + /// + /// #### Returns + /// + /// an unmodifiable, insertion ordered map, never null + public Map getParameters() { + return parameters; + } + + @Override + public String toString() { + return "InviteAttribution[" + code + " " + matchType + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java new file mode 100644 index 00000000000..82bb72e34a8 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -0,0 +1,89 @@ +/* + * 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; + +import com.codename1.analytics.AbstractAnalyticsProvider; +import com.codename1.analytics.AnalyticsCapability; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.AnalyticsContext; +import com.codename1.io.Preferences; + +// The seam that lets invite attribution honour an erasure request and a +// consent change without any edit to the Analytics facade. +// +// Analytics already calls init(context) on every provider from +// resetClientId(), with a context carrying the NEW client id, and +// onConsentChanged(consent) on every provider from setConsent(). Registering +// a provider is therefore enough to observe both, and this class exists only +// to do that. +// +// It matters because Analytics.resetClientId() does not clear custom +// dimensions. That is the right default -- an application's own dimensions +// are its data and it never asked to lose them -- but the referral dimensions +// identify an inviter, so leaving them behind would re-link a freshly issued +// pseudonymous id to the same person and defeat the erasure. Widening +// resetClientId to clear everything would have taken the application's +// dimensions with it, so the scoped erase lives here instead. +// +// The provider reports no capabilities and does nothing with events; it is a +// listener wearing a provider's interface. +final class InviteAttributionProvider extends AbstractAnalyticsProvider { + // The last client id this provider saw. A change means resetClientId() + // ran, which is what an erasure request looks like from here. + private static final String PREF_LAST_CLIENT_ID = "cn1$inviteLastClientId"; + + @Override + public String getName() { + return "invite-attribution"; + } + + @Override + public void init(AnalyticsContext context) { + super.init(context); + String seen = context == null ? null : context.getClientId(); + if (seen == null) { + return; + } + String last = Preferences.get(PREF_LAST_CLIENT_ID, ""); + if (last == null || last.length() == 0) { + // First registration on this device. Record the baseline; this is + // a provider being added, not an identity being erased. + Preferences.set(PREF_LAST_CLIENT_ID, seen); + return; + } + if (!last.equals(seen)) { + Invites.eraseInternal(); + Preferences.set(PREF_LAST_CLIENT_ID, seen); + } + } + + @Override + public void onConsentChanged(AnalyticsConsent consent) { + Invites.onConsentChanged(consent != null && consent.isAnalytics()); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return false; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteListener.java b/CodenameOne/src/com/codename1/analytics/invite/InviteListener.java new file mode 100644 index 00000000000..3f8d63e2858 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteListener.java @@ -0,0 +1,58 @@ +/* + * 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 invite that caused this install, if there was one. +/// +/// Register with [Invites#setInviteListener] before calling +/// [Invites#checkForInvite]. Exactly one of the two methods is called per +/// install, on the EDT, and neither is called again on later launches -- the +/// answer is remembered. +/// +/// ```java +/// Invites.setInviteListener(new InviteListener() { +/// public void inviteReceived(InviteAttribution attribution) { +/// Dialog.show("Welcome", "Invited by " + attribution.getCampaign(), "OK", null); +/// } +/// +/// public void attributionUnavailable(String reason) { +/// } +/// }); +/// ``` +public interface InviteListener { + /// Called when this install is attributed to an invite. + /// + /// #### Parameters + /// + /// - `attribution`: the resolved attribution, never null + public void inviteReceived(InviteAttribution attribution); + + /// Called when no invite will be attributed to this install. This is the + /// ordinary outcome -- most installs are not invited -- so treat it as + /// information rather than as a failure. + /// + /// #### Parameters + /// + /// - `reason`: one of the `REASON_` constants on [Invites] + public void attributionUnavailable(String reason); +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java b/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java new file mode 100644 index 00000000000..0a10364c46d --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java @@ -0,0 +1,322 @@ +/* + * 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; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/// Describes the invite to mint. Immutable; build one with [#create]. +/// +/// ```java +/// InviteRequest request = InviteRequest.create() +/// .campaign("spring") +/// .channel("whatsapp") +/// .title("Join me") +/// .description("I am using this and thought of you.") +/// .payload("room-42") +/// .build(); +/// ``` +/// +/// `title`, `description` and `imageUrl` drive the preview card the link +/// service renders, which is what makes a shared link look like an invitation +/// in a messaging application rather than a bare address. +/// +/// [Builder#build] validates and throws `IllegalArgumentException` naming the +/// offending field, so a mistake surfaces at the call you can see rather than +/// as a silently dropped value later. +public final class InviteRequest { + /// The longest accepted [Builder#payload]. + public static final int MAX_PAYLOAD_LENGTH = 512; + + /// The longest accepted [Builder#title]. + public static final int MAX_TITLE_LENGTH = 128; + + /// The longest accepted [Builder#description]. + public static final int MAX_DESCRIPTION_LENGTH = 256; + + /// The longest accepted [Builder#campaign] or [Builder#channel]. + public static final int MAX_TOKEN_LENGTH = 64; + + private final String campaign; + private final String channel; + private final String payload; + private final String title; + private final String description; + private final String imageUrl; + private final Map parameters; + + private InviteRequest(Builder b) { + this.campaign = b.campaign; + this.channel = b.channel; + this.payload = b.payload; + this.title = b.title; + this.description = b.description; + this.imageUrl = b.imageUrl; + this.parameters = Collections.unmodifiableMap( + new LinkedHashMap(b.parameters)); + } + + /// Starts building a request. + /// + /// #### Returns + /// + /// a new builder + public static Builder create() { + return new Builder(); + } + + /// The campaign, or null. + /// + /// #### Returns + /// + /// the campaign + public String getCampaign() { + return campaign; + } + + /// The channel, or null. + /// + /// #### Returns + /// + /// the channel + public String getChannel() { + return channel; + } + + /// The application defined payload, or null. + /// + /// #### Returns + /// + /// the payload + public String getPayload() { + return payload; + } + + /// The preview card title, or null. + /// + /// #### Returns + /// + /// the title + public String getTitle() { + return title; + } + + /// The preview card description, or null. + /// + /// #### Returns + /// + /// the description + public String getDescription() { + return description; + } + + /// The preview card image address, or null. + /// + /// #### Returns + /// + /// the image address + public String getImageUrl() { + return imageUrl; + } + + /// The custom parameters carried to the invited device. + /// + /// #### Returns + /// + /// an unmodifiable, insertion ordered map, never null + public Map getParameters() { + return parameters; + } + + /// Builds an [InviteRequest]. + public static final class Builder { + private String campaign; + private String channel; + private String payload; + private String title; + private String description; + private String imageUrl; + private final Map parameters = new LinkedHashMap(); + + Builder() { + } + + /// Groups this invite with others for reporting, for example a + /// seasonal push. Letters, digits, `.`, `_` and `-` only. + /// + /// #### Parameters + /// + /// - `campaign`: the campaign name + /// + /// #### Returns + /// + /// this builder + public Builder campaign(String campaign) { + this.campaign = campaign; + return this; + } + + /// How the invite is being sent, for example `sms` or `whatsapp`. + /// Letters, digits, `.`, `_` and `-` only. + /// + /// #### Parameters + /// + /// - `channel`: the channel name + /// + /// #### Returns + /// + /// this builder + public Builder channel(String channel) { + this.channel = channel; + return this; + } + + /// An application defined string handed back to the invited device, + /// for example the room or team the friend is being invited to. + /// + /// #### Parameters + /// + /// - `payload`: the payload + /// + /// #### Returns + /// + /// this builder + public Builder payload(String payload) { + this.payload = payload; + return this; + } + + /// The headline on the preview card the link shows in a messaging + /// application. + /// + /// #### Parameters + /// + /// - `title`: the title + /// + /// #### Returns + /// + /// this builder + public Builder title(String title) { + this.title = title; + return this; + } + + /// The body text on the preview card. + /// + /// #### Parameters + /// + /// - `description`: the description + /// + /// #### Returns + /// + /// this builder + public Builder description(String description) { + this.description = description; + return this; + } + + /// The image on the preview card, as an absolute address. + /// + /// #### Parameters + /// + /// - `url`: the image address + /// + /// #### Returns + /// + /// this builder + public Builder imageUrl(String url) { + this.imageUrl = url; + return this; + } + + /// Adds a custom parameter carried to the invited device. A null + /// value removes the key. + /// + /// #### Parameters + /// + /// - `key`: the parameter name + /// + /// - `value`: the parameter value, or null to remove it + /// + /// #### Returns + /// + /// this builder + public Builder param(String key, String value) { + if (key == null || key.length() == 0) { + return this; + } + if (value == null) { + parameters.remove(key); + } else { + parameters.put(key, value); + } + return this; + } + + /// Validates and builds the request. + /// + /// #### Returns + /// + /// the immutable request + public InviteRequest build() { + checkToken("campaign", campaign); + checkToken("channel", channel); + checkLength("payload", payload, MAX_PAYLOAD_LENGTH); + checkLength("title", title, MAX_TITLE_LENGTH); + checkLength("description", description, MAX_DESCRIPTION_LENGTH); + return new InviteRequest(this); + } + + private static void checkLength(String field, String value, int max) { + if (value != null && value.length() > max) { + throw new IllegalArgumentException( + field + " is longer than " + max + " characters"); + } + } + + // Campaign and channel end up both in a url and as an analytics + // dimension value, so they are restricted to characters that are safe + // unescaped in either. Checked here rather than silently rewritten, + // because a rewritten campaign name stops matching the one in the + // report. + private static void checkToken(String field, String value) { + if (value == null) { + return; + } + if (value.length() == 0 || value.length() > MAX_TOKEN_LENGTH) { + throw new IllegalArgumentException( + field + " must be 1 to " + MAX_TOKEN_LENGTH + " characters"); + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + if (!ok) { + throw new IllegalArgumentException( + field + " may only contain letters, digits, '.', '_' and '-'"); + } + } + } + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java new file mode 100644 index 00000000000..eee5789c454 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -0,0 +1,211 @@ +/* + * 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; + +import com.codename1.io.Log; +import com.codename1.io.Storage; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +// The three durable records invite attribution keeps on the device. +// +// Storage rather than Preferences, for the reason Continuity records: a +// Preferences write discards Storage.writeObject's boolean and the matching +// read comes back out of the same in-memory map, so a failed write is +// invisible. These records decide whether a user is attributed at all and +// whether the same install is attributed twice, so a silent write failure has +// to be observable. +// +// Every record is a flat map of strings. That is what survives Util's object +// serialization on every port without registering an Externalizable, and it +// keeps the format readable if it ever has to be inspected on a device. +final class InviteStore { + // The fingerprint and the code seen before attribution resolves. + static final String PENDING = "CN1$InvitePending"; + + // The resolved attribution, plus whether the application has been told. + static final String ATTRIBUTION = "CN1$InviteAttribution"; + + // Mint registrations that have not reached the link service yet. + static final String OUTBOX = "CN1$InviteOutbox"; + + // A viral inviter can mint faster than a bad network drains the queue. + // Dropping the oldest is right: an unregistered invite still attributes + // once the server sees the click, so the newest are the ones whose + // registration is still worth racing. + static final int MAX_OUTBOX = 32; + + private InviteStore() { + } + + static Map read(String record) { + try { + Storage s = Storage.getInstance(); + if (s == null || !s.exists(record)) { + return null; + } + Object o = s.readObject(record); + if (!(o instanceof Map)) { + return null; + } + Map out = new LinkedHashMap(); + Map raw = (Map) o; + for (Iterator i = raw.keySet().iterator(); i.hasNext();) { + Object k = i.next(); + Object v = raw.get(k); + if (k instanceof String && v instanceof String) { + out.put((String) k, (String) v); + } + } + return out; + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + // Returns false when the record did not reach the disk. Callers that care + // about exactly-once behaviour check this; the rest may ignore it. + static boolean write(String record, Map values) { + try { + Storage s = Storage.getInstance(); + if (s == null) { + return false; + } + return s.writeObject(record, new LinkedHashMap(values)); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + static void delete(String record) { + try { + Storage s = Storage.getInstance(); + if (s != null && s.exists(record)) { + s.deleteStorageFile(record); + } + } catch (Throwable t) { + Log.e(t); + } + } + + static List readOutbox() { + List out = new ArrayList(); + try { + Storage s = Storage.getInstance(); + if (s == null || !s.exists(OUTBOX)) { + return out; + } + Object o = s.readObject(OUTBOX); + if (!(o instanceof List)) { + return out; + } + List raw = (List) o; + for (int i = 0; i < raw.size(); i++) { + Object v = raw.get(i); + if (v instanceof String) { + out.add((String) v); + } + } + } catch (Throwable t) { + Log.e(t); + } + return out; + } + + static void writeOutbox(List entries) { + try { + Storage s = Storage.getInstance(); + if (s == null) { + return; + } + List copy = new ArrayList(entries); + while (copy.size() > MAX_OUTBOX) { + copy.remove(0); + } + if (copy.isEmpty()) { + if (s.exists(OUTBOX)) { + s.deleteStorageFile(OUTBOX); + } + return; + } + s.writeObject(OUTBOX, copy); + } catch (Throwable t) { + Log.e(t); + } + } + + static String get(Map record, String key, String def) { + if (record == null) { + return def; + } + String v = record.get(key); + return v == null ? def : v; + } + + static long getLong(Map record, String key, long def) { + String v = get(record, key, null); + if (v == null || v.length() == 0) { + return def; + } + try { + return Long.parseLong(v); + } catch (NumberFormatException e) { + return def; + } + } + + static int getInt(Map record, String key, int def) { + return (int) getLong(record, key, def); + } + + static double getDouble(Map record, String key, double def) { + String v = get(record, key, null); + if (v == null || v.length() == 0) { + return def; + } + try { + return Double.parseDouble(v); + } catch (NumberFormatException e) { + return def; + } + } + + static boolean getBoolean(Map record, String key, boolean def) { + String v = get(record, key, null); + if (v == null) { + return def; + } + return "true".equals(v); + } + + static void put(Map record, String key, String value) { + if (value != null) { + record.put(key, value); + } + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java new file mode 100644 index 00000000000..a214f388228 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -0,0 +1,1328 @@ +/* + * 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; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.Log; +import com.codename1.io.NetworkManager; +import com.codename1.io.Preferences; +import com.codename1.io.Util; +import com.codename1.share.ShareResult; +import com.codename1.share.ShareResultListener; +import com.codename1.ui.Display; +import com.codename1.ui.geom.Rectangle; +import com.codename1.util.Base64; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/// Invite a friend, and follow the invitation through to what it caused. +/// +/// Mint an invite, share it, and on the friend's device recover the invite +/// that produced the install. Once attribution resolves it is written as +/// persistent analytics dimensions, so every later event -- including the +/// `purchase` event the framework already emits -- carries the campaign and +/// the referrer, and revenue per campaign comes out of the reports you have. +/// +/// ### Sending +/// +/// ```java +/// Invite invite = Invites.create(InviteRequest.create() +/// .campaign("spring") +/// .channel("share_sheet") +/// .build()); +/// Invites.share(invite, "Come and try this with me"); +/// ``` +/// +/// [#create] returns immediately and works with no network, so the share +/// sheet never waits on a server. Registration with the link service is +/// retried in the background. +/// +/// ### Receiving +/// +/// ```java +/// Invites.setInviteListener(new InviteListener() { +/// public void inviteReceived(InviteAttribution attribution) { +/// // attribution.getCode(), getCampaign(), getPayload() +/// } +/// +/// public void attributionUnavailable(String reason) { +/// } +/// }); +/// Invites.checkForInvite(); +/// ``` +/// +/// Call [#checkForInvite] from your `start()` method. It is a pull rather +/// than a callback on purpose: Android delivers a link by replacing the +/// activity intent and iOS by setting a property, and reading the launch +/// argument is the one path that behaves the same on both. +/// +/// ### Consent, and what is on the device before it +/// +/// Everything reported here is gated on the analytics consent category of +/// [Analytics], and nothing is transmitted until consent is granted. +/// +/// One thing does happen before consent: on first launch a coarse device +/// profile -- operating system version, hardware model, language, screen size +/// -- is written to local storage so that a deferred match is still possible +/// once consent arrives. It is never transmitted while consent is withheld, +/// and it is deleted outright if consent is refused. There is no alternative +/// that also works, because the window in which a deferred match can be made +/// closes within the hour, long before a typical consent prompt is answered. +/// [#setAttributionWindow] with `0` switches deferred attribution off +/// entirely. +/// +/// ### How exact the answer is +/// +/// [InviteAttribution#getMatchType] says how the attribution was made. +/// [#MATCH_DIRECT] and [#MATCH_REFERRER] are exact. [#MATCH_FINGERPRINT] is a +/// statistical match made on the server, used where the platform's store +/// carries no referrer, and it is occasionally wrong -- check +/// [InviteAttribution#getConfidence] and do not pay a referral bounty on it +/// without saying so. +public final class Invites { + /// Nothing has been attributed and nothing is outstanding. + public static final int STATE_NONE = 0; + + /// An invite is being resolved; the answer has not arrived yet. + public static final int STATE_PENDING = 1; + + /// This install has been attributed to an invite. + public static final int STATE_RESOLVED = 2; + + /// No invite will be attributed to this install. + public static final int STATE_NONE_FOUND = 3; + + /// Attribution was abandoned because analytics consent was refused. + public static final int STATE_DECLINED = 4; + + /// The link opened an application that was already installed. Exact. + public static final String MATCH_DIRECT = "direct"; + + /// The invite code made the whole trip through the application store and + /// came back verbatim. Exact. + public static final String MATCH_REFERRER = "referrer"; + + /// The server matched this install to a click statistically, because the + /// platform's store carries no referrer. Not exact. + public static final String MATCH_FINGERPRINT = "fingerprint"; + + /// No invite matched. The ordinary outcome for an uninvited install. + public static final String REASON_NO_MATCH = "no_match"; + + /// The attribution window closed before an answer arrived. + public static final String REASON_EXPIRED = "expired"; + + /// Analytics consent was refused, so attribution was abandoned. + public static final String REASON_CONSENT_DENIED = "consent_denied"; + + /// This platform cannot recover a deferred invite. + public static final String REASON_UNSUPPORTED = "unsupported"; + + /// The analytics category every invite event is reported under. + public static final String CATEGORY = "referral"; + + /// Dimension carrying the matched invite code. + public static final String DIMENSION_CODE = "cn1_invite_code"; + + /// Dimension carrying the campaign the invite belonged to. + public static final String DIMENSION_CAMPAIGN = "cn1_campaign"; + + /// Dimension carrying the channel the invite was sent through. + public static final String DIMENSION_CHANNEL = "cn1_channel"; + + /// Dimension carrying how the attribution was made. + public static final String DIMENSION_MATCH = "cn1_invite_match"; + + /// The default attribution window: how long after a first launch a + /// deferred invite may still be resolved. + public static final long DEFAULT_ATTRIBUTION_WINDOW = 7L * 24L * 60L * 60L * 1000L; + + static final String[] DIMENSIONS = { + DIMENSION_CODE, DIMENSION_CAMPAIGN, DIMENSION_CHANNEL, DIMENSION_MATCH + }; + + private static final String DEFAULT_BASE_URL = "https://cloud.codenameone.com"; + private static final String PATH_MINT = "/api/v2/analytics/invites"; + private static final String PATH_CLAIM = "/api/v2/analytics/invites/claim"; + private static final String PATH_MATCH = "/api/v2/analytics/invites/match"; + + private static final String PREF_SLUG = "cn1$inviteSlug"; + private static final String PREF_CONSUMED_ARG = "cn1$inviteConsumedArg"; + + // The referrer key the link service puts on the store url. Compared with + // equals and never case folded: String.toLowerCase is locale sensitive and + // has no root-locale overload here, so under a Turkish default locale the + // 'i' folds to a dotless i and the key silently stops matching on exactly + // the devices nobody can reproduce on. + private static final String REFERRER_KEY = "cn1_invite"; + + private static final int MAX_ATTEMPTS = 5; + + private static String linkBase; + private static long attributionWindow = DEFAULT_ATTRIBUTION_WINDOW; + private static boolean reattribution; + private static InviteListener listener; + private static InstallReferrerSource referrerSource; + private static InviteAttribution resolved; + private static int state = -1; + private static boolean deliveredThisRun; + private static boolean deferredStarted; + + private Invites() { + } + + /// Registers the platform hook that reads the application store's install + /// referrer. The Codename One build calls this before the application + /// starts on platforms that have one; an application does not. + /// + /// #### Parameters + /// + /// - `source`: the platform source, or null to remove it + public static void registerInstallReferrerSource(InstallReferrerSource source) { + referrerSource = source; + } + + // ---- sending --------------------------------------------------------- + + /// Mints an invite and returns it immediately. + /// + /// This never blocks and never fails for want of a network. The code is + /// generated on the device, so [Invite#getUrl] is usable at once; + /// registration with the link service is queued and retried until it + /// lands. A link clicked before that registration arrives is still + /// attributed, because the server records the click against the code and + /// joins it when the registration turns up. + /// + /// #### Parameters + /// + /// - `request`: what to mint, must not be null + /// + /// #### Returns + /// + /// the invite, never null + public static Invite create(InviteRequest request) { + if (request == null) { + throw new IllegalArgumentException("request is null"); + } + ensureProvider(); + String code = newCode(); + long now = System.currentTimeMillis(); + Invite invite = new Invite(code, buildUrl(code), request.getCampaign(), + request.getChannel(), request.getPayload(), now, false); + queueRegistration(invite, request); + Map p = new HashMap(); + p.put("invite_code", code); + putIfSet(p, "campaign", request.getCampaign()); + putIfSet(p, "channel", request.getChannel()); + Analytics.autoEvent("invite_created", CATEGORY, p); + flush(); + return invite; + } + + /// Shares an invite through the native share sheet. + /// + /// #### Parameters + /// + /// - `invite`: the invite to share, must not be null + /// + /// - `message`: text placed before the link, or null for the link alone + public static void share(Invite invite, String message) { + share(invite, message, null, null); + } + + /// Shares an invite through the native share sheet and reports the + /// outcome. + /// + /// The invite funnel's `invite_shared` event is emitted from here, and + /// only when the platform confirms the user actually shared -- a + /// dismissed sheet reports `invite_share_dismissed` instead. That is what + /// makes the "shared" number a measurement rather than an assumption. + /// + /// #### Parameters + /// + /// - `invite`: the invite to share, must not be null + /// + /// - `message`: text placed before the link, or null for the link alone + /// + /// - `sourceRect`: popover anchor hint, may be null + /// + /// - `resultListener`: receives the share outcome, may be null + public static void share(Invite invite, String message, Rectangle sourceRect, + ShareResultListener resultListener) { + if (invite == null) { + throw new IllegalArgumentException("invite is null"); + } + Display d = Display.getInstance(); + if (d == null) { + return; + } + String text = message == null || message.length() == 0 + ? invite.getUrl() : message + " " + invite.getUrl(); + d.share(text, null, null, sourceRect, chain(invite, resultListener)); + } + + /// Reports the outcome of a share your application performed itself, + /// rather than through [#share]. Use this when the invite goes out + /// through your own user interface -- a contact picker, a message + /// composer, a copy-link button -- so the funnel still records whether it + /// was really sent. + /// + /// `invite_shared` is emitted only when `result` says the user actually + /// shared; a dismissed sheet reports `invite_share_dismissed` instead. + /// Calling this is optional and calling it twice for one share double + /// counts, so call it once, from the share callback. + /// + /// #### Parameters + /// + /// - `invite`: the invite that was shared, must not be null + /// + /// - `result`: the outcome the platform reported, may be null + public static void reportShareResult(Invite invite, ShareResult result) { + if (invite == null || result == null) { + return; + } + Map p = new HashMap(); + p.put("invite_code", invite.getCode()); + putIfSet(p, "campaign", invite.getCampaign()); + putIfSet(p, "channel", invite.getChannel()); + if (result.isSharedTo()) { + // May legitimately be null on older Android and the web share + // api. Omitted rather than filled with a placeholder, so the + // console's unknown rate stays honest. + putIfSet(p, "target", result.getPackageName()); + Analytics.autoEvent("invite_shared", CATEGORY, p); + } else if (result.isDismissed()) { + Analytics.autoEvent("invite_share_dismissed", CATEGORY, p); + } + } + + // Wraps the caller's listener so the funnel sees the real outcome and the + // caller still gets theirs. + private static ShareResultListener chain(final Invite invite, + final ShareResultListener delegate) { + return new ShareResultListener() { + @Override + public void onResult(ShareResult result) { + try { + reportShareResult(invite, result); + } catch (Throwable t) { + Log.e(t); + } + if (delegate != null) { + delegate.onResult(result); + } + } + }; + } + + // ---- receiving ------------------------------------------------------- + + /// Registers the listener that receives the invite behind this install. + /// + /// An answer that arrived before the listener was registered -- which + /// happens routinely on a cold launch from a link, because the platform + /// delivers the link before the application starts -- is delivered as + /// soon as this is called. + /// + /// #### Parameters + /// + /// - `l`: the listener, or null to remove it + public static void setInviteListener(InviteListener l) { + listener = l; + ensureProvider(); + if (l != null) { + deliverPending(); + } + } + + /// The registered listener, or null. + /// + /// #### Returns + /// + /// the listener + public static InviteListener getInviteListener() { + return listener; + } + + /// Looks for an invite: first in the launch argument, then, when this + /// looks like a fresh install, by asking the link service. + /// + /// Safe and cheap to call on every start; it will not attribute twice and + /// will not report twice. + /// + /// #### Returns + /// + /// true when the launch argument carried an invite link + public static boolean checkForInvite() { + ensureProvider(); + deliverPending(); + String appArg = null; + Display d = Display.getInstance(); + if (d != null) { + appArg = d.getProperty("AppArg", null); + } + boolean consumed = false; + if (appArg != null && appArg.length() > 0 + && !appArg.equals(Preferences.get(PREF_CONSUMED_ARG, ""))) { + consumed = handleUrl(appArg); + if (consumed) { + Preferences.set(PREF_CONSUMED_ARG, appArg); + } + } + if (!consumed) { + beginDeferred(); + } + return consumed; + } + + /// Offers a url to the invite machinery directly, for applications that + /// consume the launch argument themselves or route it through + /// `com.codename1.router`. + /// + /// #### Parameters + /// + /// - `url`: the url to inspect, may be null + /// + /// #### Returns + /// + /// true when the url carried an invite code + public static boolean handleUrl(String url) { + String code = extractCode(url); + if (code == null) { + return false; + } + ensureProvider(); + if (getState() == STATE_RESOLVED && !reattribution) { + // Already attributed. Re-engagement is worth counting, but + // rewriting the cohort mid-stream would make lifetime value per + // referrer unjoinable, so first touch stands. + Map p = new HashMap(); + p.put("invite_code", code); + p.put("match", MATCH_DIRECT); + Analytics.autoEvent("invite_opened", CATEGORY, p); + return true; + } + Map pending = pendingRecord(); + pending.put("code", code); + InviteStore.write(InviteStore.PENDING, pending); + setState(STATE_PENDING); + claim(code, "universal_link", "", MATCH_DIRECT, false); + return true; + } + + /// The attribution for this install, or null when there is none yet. + /// + /// #### Returns + /// + /// the attribution + public static InviteAttribution getAttribution() { + if (resolved == null) { + resolved = readAttribution(); + } + return resolved; + } + + /// Where attribution has got to: one of the `STATE_` constants. + /// + /// #### Returns + /// + /// the current state + public static int getState() { + if (state < 0) { + if (getAttribution() != null) { + state = STATE_RESOLVED; + } else { + Map pending = InviteStore.read(InviteStore.PENDING); + state = pending == null ? STATE_NONE + : InviteStore.getInt(pending, "state", STATE_PENDING); + } + } + return state; + } + + // ---- closing the funnel --------------------------------------------- + + /// Reports that the invited user reached the outcome the invite existed + /// for -- signed up, joined the room, completed onboarding. No-op unless + /// this install was attributed. + /// + /// #### Parameters + /// + /// - `action`: what the user did + public static void conversion(String action) { + conversion(action, 0d, null); + } + + /// Reports a conversion carrying a value, so revenue can be attributed to + /// the campaign and the referrer. No-op unless this install was + /// attributed. + /// + /// #### Parameters + /// + /// - `action`: what the user did + /// + /// - `value`: the value of the conversion + /// + /// - `currency`: the currency code, or null + public static void conversion(String action, double value, String currency) { + InviteAttribution a = getAttribution(); + if (a == null) { + return; + } + Map p = new HashMap(); + p.put("invite_code", a.getCode()); + putIfSet(p, "campaign", a.getCampaign()); + putIfSet(p, "channel", a.getChannel()); + putIfSet(p, "action", action); + if (value != 0d) { + p.put("value", new Double(value)); + } + putIfSet(p, "currency", currency); + Analytics.autoEvent("invite_converted", CATEGORY, p); + } + + // ---- configuration --------------------------------------------------- + + /// Points the invite machinery at a different link service. Defaults to + /// the Codename One cloud, honouring the `cloudServerURL` display + /// property. + /// + /// #### Parameters + /// + /// - `url`: the base address, with no trailing path + public static void setLinkBase(String url) { + linkBase = url; + } + + /// The link service base address in use. + /// + /// #### Returns + /// + /// the base address, never null + public static String getLinkBase() { + if (linkBase != null && linkBase.length() > 0) { + return trimSlash(linkBase); + } + Display d = Display.getInstance(); + String base = d == null ? DEFAULT_BASE_URL + : d.getProperty("cloudServerURL", DEFAULT_BASE_URL); + if (base == null || base.length() == 0) { + base = DEFAULT_BASE_URL; + } + return trimSlash(base); + } + + /// How long after a first launch a deferred invite may still be + /// resolved. Clamped to at most 30 days. Zero switches deferred + /// attribution off, which is the supported way to ship without the + /// statistical match. + /// + /// #### Parameters + /// + /// - `millis`: the window in milliseconds + public static void setAttributionWindow(long millis) { + long max = 30L * 24L * 60L * 60L * 1000L; + if (millis < 0) { + millis = 0; + } + if (millis > max) { + millis = max; + } + attributionWindow = millis; + } + + /// The attribution window in milliseconds. + /// + /// #### Returns + /// + /// the window + public static long getAttributionWindow() { + return attributionWindow; + } + + /// Whether a later invite replaces an earlier attribution. Off by + /// default: first touch stands, so a user's cohort does not change + /// underneath the reports. + /// + /// #### Parameters + /// + /// - `value`: true for last touch + public static void setReattribution(boolean value) { + reattribution = value; + } + + /// Whether last touch attribution is enabled. + /// + /// #### Returns + /// + /// true when a later invite replaces an earlier one + public static boolean isReattribution() { + return reattribution; + } + + // ---- housekeeping ---------------------------------------------------- + + /// Retries anything queued: unregistered invites, and an outstanding + /// deferred match. Called for you on the paths that matter; exposed for + /// an application that knows it has just regained connectivity. + public static void flush() { + drainOutbox(); + } + + /// Forgets every trace of invite attribution on this device: the pending + /// fingerprint, the resolved attribution and the referral dimensions. + /// + /// [Analytics#resetClientId] triggers this for you, because an erasure + /// that left the referral dimensions behind would re-link the fresh + /// identity to the same inviter. + public static void reset() { + InviteStore.delete(InviteStore.PENDING); + InviteStore.delete(InviteStore.ATTRIBUTION); + InviteStore.delete(InviteStore.OUTBOX); + Preferences.delete(PREF_CONSUMED_ARG); + clearDimensions(); + resolved = null; + state = STATE_NONE; + deliveredThisRun = false; + deferredStarted = false; + } + + // Package private: the analytics provider hook calls this when the client + // id changes underneath us, which is what an erasure request looks like. + static void eraseInternal() { + reset(); + } + + // Package private: called from the provider when consent changes. + static void onConsentChanged(boolean allowed) { + if (allowed) { + if (getState() == STATE_PENDING) { + deferredStarted = false; + beginDeferred(); + } else if (getState() == STATE_RESOLVED) { + // Re-granting restores the dimensions from the record we kept, + // without re-reporting the install or telling the app again. + InviteAttribution a = getAttribution(); + if (a != null) { + writeDimensions(a); + } + } + drainOutbox(); + return; + } + // Refused. A device profile held for a match that is no longer + // permitted has no reason to exist, so it goes now rather than at the + // end of the window. + if (getState() == STATE_PENDING) { + InviteStore.delete(InviteStore.PENDING); + setState(STATE_DECLINED); + notifyUnavailable(REASON_CONSENT_DENIED); + } + clearDimensions(); + } + + // ---- internals ------------------------------------------------------- + + // Registers the provider that gives us the erasure and consent hooks. + // Analytics.clearProviders() can drop it, so this re-registers on facade + // entry rather than only once; the provider list is a handful of entries. + private static void ensureProvider() { + try { + List providers = Analytics.getProviders(); + for (int i = 0; i < providers.size(); i++) { + if (providers.get(i) instanceof InviteAttributionProvider) { + return; + } + } + Analytics.addProvider(new InviteAttributionProvider()); + } catch (Throwable t) { + Log.e(t); + } + } + + // The ordinary gate: what Analytics itself would allow. + private static boolean allowed() { + AnalyticsConsent c = Analytics.getConsent(); + if (Analytics.getConsentMode() == ConsentMode.OPT_OUT) { + return c == null || c.isAnalytics(); + } + return c != null && c.isAnalytics(); + } + + // The strict gate, for the statistical match only. Opt-out mode reports + // permission with no user choice on record -- the deprecated + // AnalyticsService forces exactly that for legacy callers -- and sending + // a device profile under an implicit allow is not defensible. Everything + // else uses allowed(). + private static boolean explicitlyAllowed() { + AnalyticsConsent c = Analytics.getConsent(); + return c != null && c.isAnalytics(); + } + + private static String newCode() { + byte[] raw = new byte[16]; + try { + Util.secureRandomBytes(raw); + } catch (Throwable t) { + // A code identifies an invite and authorizes nothing, so a weaker + // source degrades uniqueness, not security. Reported once rather + // than failing the invite. + Log.e(t); + java.util.Random r = new java.util.Random(); + r.nextBytes(raw); + } + String s = Base64.encodeUrlSafe(raw); + int pad = s.indexOf('='); + if (pad > 0) { + s = s.substring(0, pad); + } + return s; + } + + private static String buildUrl(String code) { + String slug = Preferences.get(PREF_SLUG, ""); + if (slug != null && slug.length() > 0) { + return getLinkBase() + "/i/" + slug + "/" + code; + } + // No slug known yet -- the very first invite on a fresh install with + // no network. The bare form still redirects correctly; the server + // hands back the slugged url on registration and later invites use it. + return getLinkBase() + "/i/" + code; + } + + // Recognises our own link, or any url carrying the referrer key. The host + // is compared with regionMatches rather than folded, because case folding + // a protocol token is locale sensitive here. + static String extractCode(String url) { + if (url == null || url.length() == 0) { + return null; + } + int q = url.indexOf('?'); + if (q >= 0) { + String code = codeFromQuery(url.substring(q + 1)); + if (code != null) { + return code; + } + } + String host = hostOf(url); + if (host == null) { + return null; + } + String base = getLinkBase(); + String expected = hostOf(base); + if (expected == null || !host.regionMatches(true, 0, expected, 0, expected.length()) + || host.length() != expected.length()) { + return null; + } + String path = url; + int schemeEnd = path.indexOf("://"); + if (schemeEnd >= 0) { + int slash = path.indexOf('/', schemeEnd + 3); + if (slash < 0) { + return null; + } + path = path.substring(slash); + } + if (q >= 0) { + int rel = path.indexOf('?'); + if (rel >= 0) { + path = path.substring(0, rel); + } + } + if (!path.startsWith("/i/")) { + return null; + } + String rest = path.substring(3); + while (rest.endsWith("/")) { + rest = rest.substring(0, rest.length() - 1); + } + if (rest.length() == 0) { + return null; + } + int slash = rest.lastIndexOf('/'); + String code = slash < 0 ? rest : rest.substring(slash + 1); + if (slash > 0) { + // Remember the slug so later invites mint the precise form. + Preferences.set(PREF_SLUG, rest.substring(0, slash)); + } + return code.length() == 0 ? null : code; + } + + // Parses a referrer or query string for the invite key. Split on the + // FIRST '=' only, and compare the key with equals -- never a case fold. + static String codeFromQuery(String query) { + if (query == null || query.length() == 0) { + return null; + } + int start = 0; + while (start <= query.length()) { + int amp = query.indexOf('&', start); + String pair = amp < 0 ? query.substring(start) : query.substring(start, amp); + int eq = pair.indexOf('='); + if (eq > 0) { + String key = pair.substring(0, eq); + if (REFERRER_KEY.equals(key)) { + String value = pair.substring(eq + 1); + try { + value = Util.decode(value, "UTF-8", true); + } catch (Throwable t) { + Log.e(t); + } + return value.length() == 0 ? null : value; + } + } + if (amp < 0) { + break; + } + start = amp + 1; + } + return null; + } + + private static String hostOf(String url) { + int schemeEnd = url.indexOf("://"); + if (schemeEnd < 0) { + return null; + } + int start = schemeEnd + 3; + int end = url.length(); + for (int i = start; i < url.length(); i++) { + char c = url.charAt(i); + if (c == '/' || c == '?' || c == '#' || c == ':') { + end = i; + break; + } + } + return end > start ? url.substring(start, end) : null; + } + + private static String trimSlash(String base) { + while (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base; + } + + private static void putIfSet(Map p, String key, String value) { + if (value != null && value.length() > 0) { + p.put(key, value); + } + } + + private static void setState(int s) { + state = s; + Map pending = InviteStore.read(InviteStore.PENDING); + if (pending != null) { + pending.put("state", String.valueOf(s)); + InviteStore.write(InviteStore.PENDING, pending); + } + } + + private static Map pendingRecord() { + Map pending = InviteStore.read(InviteStore.PENDING); + if (pending != null) { + return pending; + } + pending = new LinkedHashMap(); + long now = System.currentTimeMillis(); + pending.put("firstLaunch", String.valueOf(now)); + pending.put("expiresAt", String.valueOf(now + attributionWindow)); + pending.put("attempts", "0"); + pending.put("state", String.valueOf(STATE_PENDING)); + Display d = Display.getInstance(); + if (d != null) { + InviteStore.put(pending, "platform", d.getPlatformName()); + InviteStore.put(pending, "osVersion", d.getProperty("OSVer", "")); + InviteStore.put(pending, "deviceModel", + d.getProperty("DeviceHardwareModel", d.getProperty("DeviceName", ""))); + pending.put("screenWidth", String.valueOf(d.getDisplayWidth())); + pending.put("screenHeight", String.valueOf(d.getDisplayHeight())); + } + Locale loc = Locale.getDefault(); + InviteStore.put(pending, "locale", loc == null ? "" : loc.toString()); + InviteStore.write(InviteStore.PENDING, pending); + return pending; + } + + private static void beginDeferred() { + if (deferredStarted) { + return; + } + int s = getState(); + if (s == STATE_RESOLVED || s == STATE_NONE_FOUND || s == STATE_DECLINED) { + return; + } + if (attributionWindow == 0) { + setState(STATE_NONE_FOUND); + notifyUnavailable(REASON_UNSUPPORTED); + return; + } + Map pending = pendingRecord(); + long expires = InviteStore.getLong(pending, "expiresAt", 0); + if (expires > 0 && System.currentTimeMillis() > expires) { + InviteStore.delete(InviteStore.PENDING); + state = STATE_NONE_FOUND; + notifyUnavailable(REASON_EXPIRED); + return; + } + if (InviteStore.getInt(pending, "attempts", 0) >= MAX_ATTEMPTS) { + state = STATE_NONE_FOUND; + notifyUnavailable(REASON_NO_MATCH); + return; + } + setState(STATE_PENDING); + if (!allowed()) { + // Nothing leaves the device. The record stays; onConsentChanged + // restarts this the moment consent arrives. + return; + } + deferredStarted = true; + String code = InviteStore.get(pending, "code", null); + if (code != null && code.length() > 0) { + claim(code, "universal_link", "", MATCH_DIRECT, false); + return; + } + InstallReferrerSource source = referrerSource; + if (source != null && safeSupported(source)) { + requestReferrer(source); + return; + } + requestMatch(pending); + } + + private static boolean safeSupported(InstallReferrerSource source) { + try { + return source.isSupported(); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + private static void requestReferrer(InstallReferrerSource source) { + try { + source.requestReferrer(new InstallReferrerCallback() { + @Override + public void onReferrer(final String rawReferrer, final long clickSeconds, + final long beginSeconds) { + onEdt(new Runnable() { + public void run() { + String code = codeFromQuery(rawReferrer); + if (code == null) { + fallBackToMatch(); + return; + } + claim(code, "install_referrer", + rawReferrer == null ? "" : rawReferrer, + MATCH_REFERRER, true); + } + }); + } + + @Override + public void onUnavailable(String reason) { + onEdt(new Runnable() { + public void run() { + fallBackToMatch(); + } + }); + } + }); + } catch (Throwable t) { + Log.e(t); + fallBackToMatch(); + } + } + + // No store referrer: either the device has no store client, or this was + // an organic install. Either way the statistical match is the only path + // left, and it is the same one iOS always takes. + private static void fallBackToMatch() { + Map pending = InviteStore.read(InviteStore.PENDING); + if (pending == null) { + return; + } + requestMatch(pending); + } + + private static void onEdt(Runnable r) { + Display d = Display.getInstance(); + if (d == null) { + r.run(); + return; + } + if (d.isEdt()) { + r.run(); + } else { + d.callSerially(r); + } + } + + private static void requestMatch(Map pending) { + if (!explicitlyAllowed()) { + return; + } + bumpAttempts(pending); + Map body = identity(); + body.put("platform", InviteStore.get(pending, "platform", "")); + body.put("osVersion", InviteStore.get(pending, "osVersion", "")); + body.put("deviceModel", InviteStore.get(pending, "deviceModel", "")); + body.put("locale", InviteStore.get(pending, "locale", "")); + body.put("screenWidth", new Integer(InviteStore.getInt(pending, "screenWidth", 0))); + body.put("screenHeight", new Integer(InviteStore.getInt(pending, "screenHeight", 0))); + post(getLinkBase() + PATH_MATCH, body, MATCH_FINGERPRINT, true); + } + + private static void claim(String code, String source, String rawReferrer, + final String matchType, final boolean deferred) { + if (!allowed()) { + return; + } + Map pending = InviteStore.read(InviteStore.PENDING); + if (pending != null) { + bumpAttempts(pending); + } + Map body = identity(); + body.put("code", code); + body.put("source", source); + body.put("rawReferrer", rawReferrer == null ? "" : rawReferrer); + post(getLinkBase() + PATH_CLAIM, body, matchType, deferred); + } + + private static void bumpAttempts(Map pending) { + pending.put("attempts", + String.valueOf(InviteStore.getInt(pending, "attempts", 0) + 1)); + InviteStore.write(InviteStore.PENDING, pending); + } + + private static Map identity() { + Map body = new LinkedHashMap(); + Display d = Display.getInstance(); + body.put("clientId", Analytics.clientId()); + body.put("buildKey", d == null ? "" : d.getProperty("build_key", "")); + body.put("packageName", d == null ? "" : d.getProperty("package_name", "")); + body.put("consentAnalytics", Boolean.valueOf(allowed())); + return body; + } + + private static void post(String url, Map body, final String matchType, + final boolean deferred) { + try { + ConnectionRequest req = new ConnectionRequest() { + private String payload; + + @Override + protected void readResponse(InputStream input) throws IOException { + byte[] data = Util.readInputStream(input); + payload = data == null ? null : new String(data, "UTF-8"); + } + + @Override + protected void postResponse() { + handleResolution(payload, matchType, deferred); + } + }; + req.setUrl(url); + req.setPost(true); + req.setContentType("application/json"); + req.setRequestBody(JSONParser.mapToJson(body)); + req.setFailSilently(true); + NetworkManager.getInstance().addToQueue(req); + } catch (Throwable t) { + Log.e(t); + } + } + + private static void handleResolution(String payload, String matchType, boolean deferred) { + try { + if (payload == null || payload.length() == 0) { + return; + } + Map json = JSONParser.parseJSON(payload); + if (json == null) { + return; + } + Object slug = json.get("slug"); + if (slug instanceof String && ((String) slug).length() > 0) { + Preferences.set(PREF_SLUG, (String) slug); + } + if (!truthy(json.get("resolved"))) { + state = STATE_NONE_FOUND; + notifyUnavailable(REASON_NO_MATCH); + return; + } + String code = str(json.get("code")); + if (code == null) { + return; + } + String confidence = str(json.get("confidence")); + double score = 1d; + Object rawScore = json.get("score"); + if (rawScore instanceof Number) { + double s = ((Number) rawScore).doubleValue(); + score = s > 1d ? s / 100d : s; + } else if (MATCH_FINGERPRINT.equals(matchType)) { + score = 0d; + } + if (MATCH_DIRECT.equals(matchType) || MATCH_REFERRER.equals(matchType)) { + score = 1d; + } + Map params = new LinkedHashMap(); + Object rawParams = json.get("parameters"); + if (rawParams instanceof Map) { + Map raw = (Map) rawParams; + for (java.util.Iterator i = raw.keySet().iterator(); i.hasNext();) { + Object k = i.next(); + Object v = raw.get(k); + if (k instanceof String && v instanceof String) { + params.put((String) k, (String) v); + } + } + } + String serverMatch = str(json.get("match")); + InviteAttribution a = new InviteAttribution(code, str(json.get("campaign")), + str(json.get("channel")), str(json.get("payload")), + serverMatch == null ? matchType : serverMatch, score, deferred, + longOf(json.get("clickTs")), System.currentTimeMillis(), params); + resolve(a, confidence); + } catch (Throwable t) { + Log.e(t); + } + } + + private static void resolve(InviteAttribution a, String confidence) { + Map record = new LinkedHashMap(); + record.put("code", a.getCode()); + InviteStore.put(record, "campaign", a.getCampaign()); + InviteStore.put(record, "channel", a.getChannel()); + InviteStore.put(record, "payload", a.getPayload()); + record.put("match", a.getMatchType()); + record.put("confidence", String.valueOf(a.getConfidence())); + record.put("deferred", String.valueOf(a.isDeferred())); + record.put("clickTs", String.valueOf(a.getClickTimestamp())); + record.put("resolvedTs", String.valueOf(a.getResolvedTimestamp())); + record.put("delivered", "false"); + InviteStore.write(InviteStore.ATTRIBUTION, record); + InviteStore.delete(InviteStore.PENDING); + resolved = a; + state = STATE_RESOLVED; + writeDimensions(a); + Map p = new HashMap(); + p.put("invite_code", a.getCode()); + putIfSet(p, "campaign", a.getCampaign()); + putIfSet(p, "channel", a.getChannel()); + p.put("match", a.getMatchType()); + putIfSet(p, "confidence", confidence); + if (a.isDeferred()) { + p.put("deferred", Boolean.TRUE); + Analytics.autoEvent("invite_install", CATEGORY, p); + } else { + Analytics.autoEvent("invite_opened", CATEGORY, p); + } + deliverPending(); + } + + private static void writeDimensions(InviteAttribution a) { + Analytics.setDimension(DIMENSION_CODE, a.getCode()); + if (a.getCampaign() != null) { + Analytics.setDimension(DIMENSION_CAMPAIGN, a.getCampaign()); + } + if (a.getChannel() != null) { + Analytics.setDimension(DIMENSION_CHANNEL, a.getChannel()); + } + Analytics.setDimension(DIMENSION_MATCH, a.getMatchType()); + } + + private static void clearDimensions() { + for (int i = 0; i < DIMENSIONS.length; i++) { + Analytics.clearDimension(DIMENSIONS[i]); + } + } + + private static InviteAttribution readAttribution() { + Map r = InviteStore.read(InviteStore.ATTRIBUTION); + if (r == null) { + return null; + } + String code = InviteStore.get(r, "code", null); + if (code == null) { + return null; + } + return new InviteAttribution(code, InviteStore.get(r, "campaign", null), + InviteStore.get(r, "channel", null), InviteStore.get(r, "payload", null), + InviteStore.get(r, "match", MATCH_DIRECT), + InviteStore.getDouble(r, "confidence", 1d), + InviteStore.getBoolean(r, "deferred", false), + InviteStore.getLong(r, "clickTs", 0), + InviteStore.getLong(r, "resolvedTs", 0), + new LinkedHashMap()); + } + + // Delivers at most once per install. The durable flag is what survives a + // restart; deliveredThisRun covers the window between resolving and the + // flag reaching the disk, so a failed write costs at most a duplicate + // after a crash rather than one on every launch. + private static void deliverPending() { + if (listener == null || deliveredThisRun) { + return; + } + Map r = InviteStore.read(InviteStore.ATTRIBUTION); + if (r == null || InviteStore.getBoolean(r, "delivered", false)) { + return; + } + InviteAttribution a = getAttribution(); + if (a == null) { + return; + } + deliveredThisRun = true; + r.put("delivered", "true"); + InviteStore.write(InviteStore.ATTRIBUTION, r); + try { + listener.inviteReceived(a); + } catch (Throwable t) { + Log.e(t); + } + } + + private static void notifyUnavailable(String reason) { + if (listener == null || deliveredThisRun) { + return; + } + deliveredThisRun = true; + try { + listener.attributionUnavailable(reason); + } catch (Throwable t) { + Log.e(t); + } + } + + // ---- registration outbox -------------------------------------------- + + private static void queueRegistration(Invite invite, InviteRequest request) { + Map body = identity(); + body.put("code", invite.getCode()); + putIfSet(body, "campaign", invite.getCampaign()); + putIfSet(body, "channel", invite.getChannel()); + putIfSet(body, "payload", invite.getPayload()); + putIfSet(body, "title", request.getTitle()); + putIfSet(body, "description", request.getDescription()); + putIfSet(body, "imageUrl", request.getImageUrl()); + if (!request.getParameters().isEmpty()) { + body.put("parameters", new LinkedHashMap(request.getParameters())); + } + List outbox = InviteStore.readOutbox(); + outbox.add(JSONParser.mapToJson(body)); + InviteStore.writeOutbox(outbox); + } + + private static void drainOutbox() { + if (!allowed()) { + return; + } + List outbox = InviteStore.readOutbox(); + if (outbox.isEmpty()) { + return; + } + for (int i = 0; i < outbox.size(); i++) { + postRegistration(outbox.get(i)); + } + // Cleared optimistically: a registration that does not land is + // recoverable server side from the click itself, and keeping the + // entries would re-post them on every facade call. + InviteStore.writeOutbox(new java.util.ArrayList()); + } + + private static void postRegistration(final String json) { + try { + ConnectionRequest req = new ConnectionRequest() { + private String payload; + + @Override + protected void readResponse(InputStream input) throws IOException { + byte[] data = Util.readInputStream(input); + payload = data == null ? null : new String(data, "UTF-8"); + } + + @Override + protected void postResponse() { + try { + if (payload == null || payload.length() == 0) { + return; + } + Map r = JSONParser.parseJSON(payload); + if (r == null) { + return; + } + Object slug = r.get("slug"); + if (slug instanceof String && ((String) slug).length() > 0) { + Preferences.set(PREF_SLUG, (String) slug); + } + } catch (Throwable t) { + Log.e(t); + } + } + }; + req.setUrl(getLinkBase() + PATH_MINT); + req.setPost(true); + req.setContentType("application/json"); + req.setRequestBody(json); + req.setFailSilently(true); + NetworkManager.getInstance().addToQueue(req); + } catch (Throwable t) { + Log.e(t); + } + } + + private static boolean truthy(Object o) { + if (o instanceof Boolean) { + return ((Boolean) o).booleanValue(); + } + if (o instanceof String) { + return "true".equals(o); + } + return false; + } + + private static String str(Object o) { + if (o instanceof String && ((String) o).length() > 0) { + return (String) o; + } + return null; + } + + private static long longOf(Object o) { + if (o instanceof Number) { + return ((Number) o).longValue(); + } + return 0; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/package-info.java b/CodenameOne/src/com/codename1/analytics/invite/package-info.java new file mode 100644 index 00000000000..d0b1827c0eb --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/package-info.java @@ -0,0 +1,64 @@ +/* + * 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. + */ +/// Invite / referral attribution: who invited whom, who installed because of +/// it, and what that invited cohort went on to do. +/// +/// [Invites] mints an invite link, shares it through the native share sheet, +/// and -- on the friend's device -- recovers the invite that caused the +/// install. Once attribution resolves it is written as persistent analytics +/// dimensions, so every later event, including the `purchase` event the +/// framework already emits for you, arrives tagged with the campaign and the +/// referrer. Revenue and lifetime value per campaign fall out of the reports +/// you already have. +/// +/// ```java +/// // On the inviter's device. +/// Invite invite = Invites.create(InviteRequest.create() +/// .campaign("spring") +/// .channel("share_sheet") +/// .build()); +/// Invites.share(invite, "Come and try this with me"); +/// +/// // On the friend's device, from your start() method. +/// Invites.setInviteListener(new InviteListener() { +/// public void inviteReceived(InviteAttribution attribution) { +/// // Credit attribution.getCampaign() / getCode(). +/// } +/// +/// public void attributionUnavailable(String reason) { +/// // Ordinary: most installs are not invited. +/// } +/// }); +/// Invites.checkForInvite(); +/// ``` +/// +/// Everything here is gated on the analytics consent category of +/// {@link com.codename1.analytics.Analytics}, and nothing is reported until +/// consent is granted. +/// +/// This package is deliberately separate from +/// {@link com.codename1.analytics}. The Android half of the attribution links +/// the Play Install Referrer library, which raises the application's minimum +/// API level, and the build only does that for applications that actually +/// reference this package. +package com.codename1.analytics.invite; diff --git a/CodenameOne/src/com/codename1/components/InviteButton.java b/CodenameOne/src/com/codename1/components/InviteButton.java new file mode 100644 index 00000000000..d4ddd9deda3 --- /dev/null +++ b/CodenameOne/src/com/codename1/components/InviteButton.java @@ -0,0 +1,262 @@ +/* + * 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.components; + +import com.codename1.analytics.invite.Invite; +import com.codename1.analytics.invite.InviteRequest; +import com.codename1.analytics.invite.Invites; +import com.codename1.share.ShareResultListener; +import com.codename1.ui.FontImage; +import com.codename1.ui.events.ActionEvent; + +/// A [ShareButton] that mints a fresh invite on every press and shares it, so +/// the whole invite funnel is wired with one component. +/// +/// ```java +/// InviteButton invite = new InviteButton("Invite a friend"); +/// invite.setCampaign("spring"); +/// invite.setMessage("Come and try this with me"); +/// form.add(invite); +/// ``` +/// +/// The button owns the share result, so `invite_shared` is reported only when +/// the platform confirms the user really shared. Your own +/// [#setShareResultListener] still works and is still called. +/// +/// See [Invites] for the receiving half and for how attribution reaches your +/// analytics reports. +public class InviteButton extends ShareButton { + private String campaign; + private String channel; + private String payload; + private String message; + private Invite invite; + private ShareResultListener appListener; + + /// Default constructor. + public InviteButton() { + setUIID("InviteButton"); + FontImage.setMaterialIcon(this, FontImage.MATERIAL_GROUP_ADD); + installChain(); + } + + /// Creates a button with the given label. + /// + /// #### Parameters + /// + /// - `text`: the button label + public InviteButton(String text) { + this(); + setText(text); + } + + // ShareButton.actionPerformed reads its private listener FIELD, not the + // getter, so the chaining listener has to be installed through super's + // setter exactly once. The overridden accessors below then keep the + // application's listener in a field of our own -- without that, setting a + // listener would silently replace the chain and the funnel would lose + // every share. + private void installChain() { + super.setShareResultListener(new ShareResultListener() { + @Override + public void onResult(com.codename1.share.ShareResult result) { + Invite current = invite; + if (current != null) { + Invites.reportShareResult(current, result); + } + if (appListener != null) { + appListener.onResult(result); + } + } + }); + } + + /// Groups the invites this button mints under a campaign. + /// + /// #### Parameters + /// + /// - `campaign`: the campaign name + public void setCampaign(String campaign) { + this.campaign = campaign; + } + + /// The campaign, or null. + /// + /// #### Returns + /// + /// the campaign + public String getCampaign() { + return campaign; + } + + /// Records how the invite is being sent. + /// + /// #### Parameters + /// + /// - `channel`: the channel name + public void setChannel(String channel) { + this.channel = channel; + } + + /// The channel, or null. + /// + /// #### Returns + /// + /// the channel + public String getChannel() { + return channel; + } + + /// An application defined string handed to the invited device. + /// + /// #### Parameters + /// + /// - `payload`: the payload + public void setPayload(String payload) { + this.payload = payload; + } + + /// The payload, or null. + /// + /// #### Returns + /// + /// the payload + public String getPayload() { + return payload; + } + + /// The text placed before the link in the shared message. + /// + /// #### Parameters + /// + /// - `message`: the message + public void setMessage(String message) { + this.message = message; + } + + /// The message, or null. + /// + /// #### Returns + /// + /// the message + public String getMessage() { + return message; + } + + /// The invite minted for the most recent press, or null before the first + /// press. + /// + /// #### Returns + /// + /// the invite + public Invite getInvite() { + return invite; + } + + /// {@inheritDoc} + @Override + public void setShareResultListener(ShareResultListener listener) { + this.appListener = listener; + } + + /// {@inheritDoc} + @Override + public ShareResultListener getShareResultListener() { + return appListener; + } + + /// {@inheritDoc} + @Override + public void actionPerformed(ActionEvent evt) { + InviteRequest.Builder b = InviteRequest.create(); + if (campaign != null) { + b.campaign(campaign); + } + if (channel != null) { + b.channel(channel); + } + if (payload != null) { + b.payload(payload); + } + invite = Invites.create(b.build()); + String text = message == null || message.length() == 0 + ? invite.getUrl() : message + " " + invite.getUrl(); + setTextToShare(text); + // ShareButton defers the share by one EDT cycle, so setting the text + // here is in time. + super.actionPerformed(evt); + } + + /// {@inheritDoc} + @Override + public String[] getPropertyNames() { + return new String[]{"textToShare", "campaign", "channel", "payload", "message"}; + } + + /// {@inheritDoc} + @Override + public Class[] getPropertyTypes() { + return new Class[]{String.class, String.class, String.class, String.class, String.class}; + } + + /// {@inheritDoc} + @Override + public Object getPropertyValue(String name) { + if ("campaign".equals(name)) { + return getCampaign(); + } + if ("channel".equals(name)) { + return getChannel(); + } + if ("payload".equals(name)) { + return getPayload(); + } + if ("message".equals(name)) { + return getMessage(); + } + return super.getPropertyValue(name); + } + + /// {@inheritDoc} + @Override + public String setPropertyValue(String name, Object value) { + String v = value instanceof String ? (String) value : null; + if ("campaign".equals(name)) { + setCampaign(v); + return null; + } + if ("channel".equals(name)) { + setChannel(v); + return null; + } + if ("payload".equals(name)) { + setPayload(v); + return null; + } + if ("message".equals(name)) { + setMessage(v); + return null; + } + return super.setPropertyValue(name, value); + } +} diff --git a/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java b/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java index ac951abbc16..d7f58731363 100644 --- a/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java +++ b/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java @@ -830,6 +830,35 @@ public final class PlatformFeatureCatalog { .androidMetaData("com.google.ar.core", "optional") .description("Cross-platform augmented reality (world/image/face tracking)")); + // Invite / referral attribution. The Play Install Referrer library is + // the deterministic half of the attribution and carries a minSdk 21 + // floor of its own, so this entry MUST stay keyed on the invite + // subpackage rather than on com/codename1/analytics/. Display and the + // Analytics facade are referenced by practically every application; + // keying one package higher would put a Play dependency and an API 21 + // floor on all of them, which is the DatabaseConfig failure recorded + // in AndroidGradleBuilder.usesClass -- and the later deletion of the + // unused sources does not undo it, because the dependency and the + // floor are already in the gradle file. + // + // No iOS half: there is nothing to link. The iOS attribution path is + // an HTTPS call built from Display properties that already exist, so + // it needs no pod, no framework and no deployment target lift. + e.add(new Entry("com/codename1/analytics/invite/") + .androidGradle("com.android.installreferrer:installreferrer:2.2") + .androidMinimumSdk(21) + .description("Invite referral attribution (Play Install Referrer)")); + + // InviteButton lives beside ShareButton in com/codename1/components, + // outside the prefix above, and an application can reference it + // without naming anything in the invite package. Matched as an exact + // class (no trailing slash) so the rest of com/codename1/components + // is unaffected. + e.add(new Entry("com/codename1/components/InviteButton") + .androidGradle("com.android.installreferrer:installreferrer:2.2") + .androidMinimumSdk(21) + .description("Invite button (Play Install Referrer)")); + ENTRIES = Collections.unmodifiableList(e); Set classPrefixes = new LinkedHashSet(); Set methodKeys = new LinkedHashSet(); diff --git a/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java b/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java index 806ac91d664..6986b65fd78 100644 --- a/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java +++ b/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java @@ -33,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -888,4 +889,57 @@ void theSharedNearbyPackageCostsNothing() { assertTrue(PlatformFeatureCatalog.matchesFor( "com/codename1/nearby/spi/NearbyBridge").isEmpty()); } + @Test + void inviteAttributionBuysThePlayInstallReferrerAndItsFloor() { + List hits = PlatformFeatureCatalog.matchesFor( + "com/codename1/analytics/invite/Invites"); + assertEquals(1, hits.size(), "expected one entry to fire"); + PlatformFeatureCatalog.Entry e = hits.get(0); + assertTrue(e.androidGradleDeps().get(0) + .startsWith("com.android.installreferrer:installreferrer"), + "the deterministic Android path needs the Play referrer library"); + assertEquals(21, e.androidMinimumSdk(), + "the installreferrer aar declares minSdk 21 and the merger enforces it"); + assertTrue(e.iosPods().isEmpty(), "the iOS path links nothing"); + assertTrue(e.iosFrameworks().isEmpty(), "the iOS path links nothing"); + assertNull(e.iosMinimumDeploymentTarget(), + "attribution over HTTPS must not lift the deployment target"); + } + + @Test + void plainAnalyticsDoesNotBuyThePlayInstallReferrer() { + // The whole reason the invite classes live in their own subpackage. + // Practically every application references the Analytics facade; if + // the entry were keyed on com/codename1/analytics/ instead, all of + // them would gain a Play dependency and an API 21 floor. That is the + // DatabaseConfig failure AndroidGradleBuilder.usesClass documents, + // and deleting the unused sources later does not undo it. + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/analytics/Analytics").isEmpty(), + "the analytics facade must buy nothing"); + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/analytics/AnalyticsEvent").isEmpty(), + "the analytics value types must buy nothing"); + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/analytics/CodenameOneAnalyticsProvider").isEmpty(), + "the first-party provider must buy nothing"); + } + + @Test + void inviteButtonIsMatchedExactlyAndLeavesTheRestOfComponentsAlone() { + // InviteButton sits beside ShareButton, outside the invite package, + // because a Button subclass does not belong in an analytics package. + // An application can reference it and nothing else, so it needs its + // own entry -- but as an exact class, or every component would fire. + List hits = PlatformFeatureCatalog.matchesFor( + "com/codename1/components/InviteButton"); + assertEquals(1, hits.size(), "expected one entry to fire"); + assertEquals(21, hits.get(0).androidMinimumSdk()); + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/components/ShareButton").isEmpty(), + "the plain share button must buy nothing"); + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/components/InfiniteProgress").isEmpty(), + "an exact-class key must not behave like a prefix"); + } } From 1a5055e0cfba074b1326e2804c1188c39b51a228 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:57:33 +0300 Subject: [PATCH 02/70] Invite attribution: tests, and the analysis gates they have to pass 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. --- .../analytics/invite/InviteStore.java | 44 ++-- .../codename1/analytics/invite/Invites.java | 167 ++++++++------- maven/core-unittests/spotbugs-exclude.xml | 14 ++ .../invite/InviteConsentAndErasureTest.java | 196 ++++++++++++++++++ .../analytics/invite/InviteDeliveryTest.java | 194 +++++++++++++++++ .../invite/InviteFunnelEventsTest.java | 170 +++++++++++++++ .../analytics/invite/InviteMintTest.java | 159 ++++++++++++++ .../analytics/invite/InviteTestSupport.java | 77 +++++++ .../invite/InviteUrlParsingTest.java | 100 +++++++++ .../analytics/invite/RecordingProvider.java | 88 ++++++++ 10 files changed, 1115 insertions(+), 94 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/RecordingProvider.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index eee5789c454..cd973d32d97 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -68,19 +68,26 @@ static Map read(String record) { return null; } Object o = s.readObject(record); - if (!(o instanceof Map)) { - return null; - } - Map out = new LinkedHashMap(); - Map raw = (Map) o; - for (Iterator i = raw.keySet().iterator(); i.hasNext();) { - Object k = i.next(); - Object v = raw.get(k); - if (k instanceof String && v instanceof String) { - out.put((String) k, (String) v); + // Positive instanceof guards throughout: ParparVM does not throw + // on a failed cast, so the catch below would never see one and the + // wrong object would simply be read as the wrong type. + if (o instanceof Map) { + Map raw = (Map) o; + Map out = new LinkedHashMap(); + for (Iterator i = raw.entrySet().iterator(); i.hasNext();) { + Object next = i.next(); + if (next instanceof Map.Entry) { + Map.Entry en = (Map.Entry) next; + Object k = en.getKey(); + Object v = en.getValue(); + if (k instanceof String && v instanceof String) { + out.put((String) k, (String) v); + } + } } + return out; } - return out; + return null; } catch (Throwable t) { Log.e(t); return null; @@ -121,14 +128,13 @@ static List readOutbox() { return out; } Object o = s.readObject(OUTBOX); - if (!(o instanceof List)) { - return out; - } - List raw = (List) o; - for (int i = 0; i < raw.size(); i++) { - Object v = raw.get(i); - if (v instanceof String) { - out.add((String) v); + if (o instanceof List) { + List raw = (List) o; + for (int i = 0; i < raw.size(); i++) { + Object v = raw.get(i); + if (v instanceof String) { + out.add((String) v); + } } } } catch (Throwable t) { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index a214f388228..e6f04e30427 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -175,8 +175,9 @@ public final class Invites { private static final String PATH_CLAIM = "/api/v2/analytics/invites/claim"; private static final String PATH_MATCH = "/api/v2/analytics/invites/match"; - private static final String PREF_SLUG = "cn1$inviteSlug"; - private static final String PREF_CONSUMED_ARG = "cn1$inviteConsumedArg"; + // Package private so the unit tests can clear them between cases. + static final String PREF_SLUG = "cn1$inviteSlug"; + static final String PREF_CONSUMED_ARG = "cn1$inviteConsumedArg"; // The referrer key the link service puts on the store url. Compared with // equals and never case folded: String.toLowerCase is locale sensitive and @@ -197,6 +198,10 @@ public final class Invites { private static boolean deliveredThisRun; private static boolean deferredStarted; + // Only ever touched on the fallback path in newCode(), and held as a field + // so there is one generator for the process rather than one per call. + private static final java.util.Random FALLBACK_RANDOM = new java.util.Random(); + private Invites() { } @@ -445,10 +450,12 @@ public static boolean handleUrl(String url) { /// /// the attribution public static InviteAttribution getAttribution() { - if (resolved == null) { - resolved = readAttribution(); + InviteAttribution a = resolved; + if (a == null) { + a = readAttribution(); + resolved = a; } - return resolved; + return a; } /// Where attribution has got to: one of the `STATE_` constants. @@ -504,7 +511,7 @@ public static void conversion(String action, double value, String currency) { putIfSet(p, "channel", a.getChannel()); putIfSet(p, "action", action); if (value != 0d) { - p.put("value", new Double(value)); + p.put("value", Double.valueOf(value)); } putIfSet(p, "currency", currency); Analytics.autoEvent("invite_converted", CATEGORY, p); @@ -697,8 +704,7 @@ private static String newCode() { // source degrades uniqueness, not security. Reported once rather // than failing the invite. Log.e(t); - java.util.Random r = new java.util.Random(); - r.nextBytes(raw); + FALLBACK_RANDOM.nextBytes(raw); } String s = Base64.encodeUrlSafe(raw); int pad = s.indexOf('='); @@ -997,8 +1003,8 @@ private static void requestMatch(Map pending) { body.put("osVersion", InviteStore.get(pending, "osVersion", "")); body.put("deviceModel", InviteStore.get(pending, "deviceModel", "")); body.put("locale", InviteStore.get(pending, "locale", "")); - body.put("screenWidth", new Integer(InviteStore.getInt(pending, "screenWidth", 0))); - body.put("screenHeight", new Integer(InviteStore.getInt(pending, "screenHeight", 0))); + body.put("screenWidth", Integer.valueOf(InviteStore.getInt(pending, "screenWidth", 0))); + body.put("screenHeight", Integer.valueOf(InviteStore.getInt(pending, "screenHeight", 0))); post(getLinkBase() + PATH_MATCH, body, MATCH_FINGERPRINT, true); } @@ -1034,27 +1040,19 @@ private static Map identity() { return body; } - private static void post(String url, Map body, final String matchType, - final boolean deferred) { - try { - ConnectionRequest req = new ConnectionRequest() { - private String payload; - - @Override - protected void readResponse(InputStream input) throws IOException { - byte[] data = Util.readInputStream(input); - payload = data == null ? null : new String(data, "UTF-8"); - } + private static void post(String url, Map body, String matchType, + boolean deferred) { + send(url, JSONParser.mapToJson(body), matchType, deferred, false); + } - @Override - protected void postResponse() { - handleResolution(payload, matchType, deferred); - } - }; + private static void send(String url, String json, String matchType, boolean deferred, + boolean registration) { + try { + InviteConnection req = new InviteConnection(matchType, deferred, registration); req.setUrl(url); req.setPost(true); req.setContentType("application/json"); - req.setRequestBody(JSONParser.mapToJson(body)); + req.setRequestBody(json); req.setFailSilently(true); NetworkManager.getInstance().addToQueue(req); } catch (Throwable t) { @@ -1062,19 +1060,71 @@ protected void postResponse() { } } - private static void handleResolution(String payload, String matchType, boolean deferred) { + // One request type for every invite call. Named rather than anonymous so + // the two call sites share a single implementation, and so the equals() + // exemption a one-shot request needs is scoped to one class. + private static final class InviteConnection extends ConnectionRequest { + private final String matchType; + private final boolean deferred; + private final boolean registration; + private String payload; + + InviteConnection(String matchType, boolean deferred, boolean registration) { + this.matchType = matchType; + this.deferred = deferred; + this.registration = registration; + } + + @Override + protected void readResponse(InputStream input) throws IOException { + payload = new String(Util.readInputStream(input), "UTF-8"); + } + + @Override + protected void postResponse() { + if (registration) { + applySlug(payload); + } else { + handleResolution(payload, matchType, deferred); + } + } + } + + // The link service hands back the per-application path segment on any + // answer. Remembering it is what lets later invites mint the precise form + // that keeps two enrolled applications on one device from claiming each + // other's links. + private static void applySlug(String payload) { try { if (payload == null || payload.length() == 0) { return; } - Map json = JSONParser.parseJSON(payload); - if (json == null) { + Map r = JSONParser.parseJSON(payload); + if (r == null) { return; } - Object slug = json.get("slug"); + Object slug = r.get("slug"); if (slug instanceof String && ((String) slug).length() > 0) { Preferences.set(PREF_SLUG, (String) slug); } + } catch (Throwable t) { + Log.e(t); + } + } + + // Package private rather than private so the unit tests can drive the real + // resolution path with a canned server answer instead of racing the + // network thread. + static void handleResolution(String payload, String matchType, boolean deferred) { + try { + if (payload == null || payload.length() == 0) { + return; + } + Map json = JSONParser.parseJSON(payload); + if (json == null) { + return; + } + applySlug(payload); if (!truthy(json.get("resolved"))) { state = STATE_NONE_FOUND; notifyUnavailable(REASON_NO_MATCH); @@ -1100,11 +1150,15 @@ private static void handleResolution(String payload, String matchType, boolean d Object rawParams = json.get("parameters"); if (rawParams instanceof Map) { Map raw = (Map) rawParams; - for (java.util.Iterator i = raw.keySet().iterator(); i.hasNext();) { - Object k = i.next(); - Object v = raw.get(k); - if (k instanceof String && v instanceof String) { - params.put((String) k, (String) v); + for (java.util.Iterator i = raw.entrySet().iterator(); i.hasNext();) { + Object next = i.next(); + if (next instanceof Map.Entry) { + Map.Entry en = (Map.Entry) next; + Object k = en.getKey(); + Object v = en.getValue(); + if (k instanceof String && v instanceof String) { + params.put((String) k, (String) v); + } } } } @@ -1261,45 +1315,8 @@ private static void drainOutbox() { InviteStore.writeOutbox(new java.util.ArrayList()); } - private static void postRegistration(final String json) { - try { - ConnectionRequest req = new ConnectionRequest() { - private String payload; - - @Override - protected void readResponse(InputStream input) throws IOException { - byte[] data = Util.readInputStream(input); - payload = data == null ? null : new String(data, "UTF-8"); - } - - @Override - protected void postResponse() { - try { - if (payload == null || payload.length() == 0) { - return; - } - Map r = JSONParser.parseJSON(payload); - if (r == null) { - return; - } - Object slug = r.get("slug"); - if (slug instanceof String && ((String) slug).length() > 0) { - Preferences.set(PREF_SLUG, (String) slug); - } - } catch (Throwable t) { - Log.e(t); - } - } - }; - req.setUrl(getLinkBase() + PATH_MINT); - req.setPost(true); - req.setContentType("application/json"); - req.setRequestBody(json); - req.setFailSilently(true); - NetworkManager.getInstance().addToQueue(req); - } catch (Throwable t) { - Log.e(t); - } + private static void postRegistration(String json) { + send(getLinkBase() + PATH_MINT, json, MATCH_DIRECT, false, true); } private static boolean truthy(Object o) { diff --git a/maven/core-unittests/spotbugs-exclude.xml b/maven/core-unittests/spotbugs-exclude.xml index 70f3ede56a1..d3a0f9ea280 100644 --- a/maven/core-unittests/spotbugs-exclude.xml +++ b/maven/core-unittests/spotbugs-exclude.xml @@ -409,4 +409,18 @@ + + + + + + diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java new file mode 100644 index 00000000000..343fb97af3c --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -0,0 +1,196 @@ +/* + * 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; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; +import com.codename1.io.ConnectionRequest; +import com.codename1.io.Storage; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InviteConsentAndErasureTest extends UITestBase { + + @AfterEach + void cleanUp() { + InviteTestSupport.tearDown(); + } + + @FormTest + void resolutionWritesTheReferralDimensions() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + Map dims = Analytics.getDimensions(); + assertEquals("ABC123", dims.get(Invites.DIMENSION_CODE)); + assertEquals("spring", dims.get(Invites.DIMENSION_CAMPAIGN)); + assertEquals("sms", dims.get(Invites.DIMENSION_CHANNEL)); + assertEquals(Invites.MATCH_REFERRER, dims.get(Invites.DIMENSION_MATCH)); + } + + @FormTest + void theDimensionsRideEveryLaterBatch() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.addProvider(new com.codename1.analytics.CodenameOneAnalyticsProvider()); + implementation.clearQueuedRequests(); + + // This is the claim the whole feature rests on: revenue per campaign + // needs no new aggregation, because the purchase event the framework + // already emits arrives carrying the attribution. + Analytics.event(com.codename1.analytics.AnalyticsEvent.create("purchase") + .param("value", 9.99).build()); + Analytics.flush(); + + List requests = implementation.getQueuedRequests(); + assertEquals(1, requests.size()); + String body = requests.get(0).getRequestBody(); + assertTrue(body.contains(Invites.DIMENSION_CAMPAIGN), body); + assertTrue(body.contains("spring"), body); + assertTrue(body.contains("purchase"), body); + } + + @FormTest + void resetClientIdErasesTheReferralDimensionsAndKeepsTheApplicationsOwn() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setDimension("plan", "pro"); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution()); + + Analytics.resetClientId(); + + // Leaving the referral dimensions behind would re-link the freshly + // issued pseudonymous id to the same inviter, which is exactly what + // the erasure was asked to undo. + Map dims = Analytics.getDimensions(); + assertNull(dims.get(Invites.DIMENSION_CODE)); + assertNull(dims.get(Invites.DIMENSION_CAMPAIGN)); + assertNull(dims.get(Invites.DIMENSION_CHANNEL)); + assertNull(dims.get(Invites.DIMENSION_MATCH)); + // ... and taking the application's own dimensions with it would be + // destroying data it never asked to lose. + assertEquals("pro", dims.get("plan")); + assertNull(Invites.getAttribution()); + assertEquals(Invites.STATE_NONE, Invites.getState()); + } + + @FormTest + void registeringTheProviderIsNotMistakenForAnErasure() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + // addProvider calls init() with the current client id, exactly as + // resetClientId does. Only a CHANGE means erase. + Analytics.addProvider(new RecordingProvider()); + Analytics.addProvider(new InviteAttributionProvider()); + + assertNotNull(Invites.getAttribution(), "a plain registration erased the attribution"); + assertEquals("spring", Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN)); + } + + @FormTest + void nothingIsTransmittedBeforeConsentAndTheProfileIsDeletedIfRefused() { + InviteTestSupport.freshInstall(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.none()); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "nothing may leave the device before consent"); + // The profile is held locally so a deferred match is still possible if + // consent arrives inside the window. + assertTrue(Storage.getInstance().exists(InviteStore.PENDING)); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + assertFalse(Storage.getInstance().exists(InviteStore.PENDING), + "a refused profile must be deleted, not held"); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + } + + @FormTest + void optOutModeAloneDoesNotAuthoriseTheStatisticalMatch() { + InviteTestSupport.freshInstall(); + // The deprecated AnalyticsService forces OPT_OUT, under which the + // ordinary gate reports permission with no user choice on record. + // Sending a device profile on that basis is not defensible, so the + // match requires an explicit grant. + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invites.checkForInvite(); + + for (ConnectionRequest r : implementation.getQueuedRequests()) { + assertFalse(r.getUrl().endsWith("/invites/match"), + "the statistical match went out under an implicit allow"); + } + } + + @FormTest + void revokingConsentClearsTheDimensionsButKeepsTheAttribution() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertNull(Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN)); + assertNotNull(Invites.getAttribution(), "the local record is not personal to anyone else"); + + Analytics.setConsent(AnalyticsConsent.granted()); + assertEquals("spring", Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN), + "re-granting must restore the dimensions from the stored record"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java new file mode 100644 index 00000000000..46977368ea6 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -0,0 +1,194 @@ +/* + * 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; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InviteDeliveryTest extends UITestBase { + + @AfterEach + void cleanUp() { + InviteTestSupport.tearDown(); + } + + private static final class Capture implements InviteListener { + final List received = new ArrayList(); + final List unavailable = new ArrayList(); + + public void inviteReceived(InviteAttribution attribution) { + received.add(attribution); + } + + public void attributionUnavailable(String reason) { + unavailable.add(reason); + } + } + + @FormTest + void anAttributionIsDeliveredExactlyOnce() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Capture capture = new Capture(); + Invites.setInviteListener(capture); + + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + assertEquals(1, capture.received.size()); + assertEquals("ABC123", capture.received.get(0).getCode()); + assertTrue(capture.received.get(0).isDeferred()); + + // Re-entering the facade, as a later start() would, must not deliver + // the same attribution a second time. + Invites.checkForInvite(); + Invites.setInviteListener(capture); + assertEquals(1, capture.received.size(), "the attribution was delivered twice"); + } + + @FormTest + void anAnswerThatArrivesBeforeTheListenerIsHeldAndDeliveredOnRegistration() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + // A cold launch from a link resolves before the application has run + // start(), so the answer has to wait rather than be dropped. + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + Capture capture = new Capture(); + Invites.setInviteListener(capture); + + assertEquals(1, capture.received.size(), "the held attribution was never delivered"); + assertEquals("ABC123", capture.received.get(0).getCode()); + } + + @FormTest + void aZeroWindowSwitchesDeferredAttributionOff() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + Capture capture = new Capture(); + Invites.setInviteListener(capture); + Invites.setAttributionWindow(0); + + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "the documented kill switch still sent something"); + assertEquals(1, capture.unavailable.size()); + assertEquals(Invites.REASON_UNSUPPORTED, capture.unavailable.get(0)); + } + + @FormTest + void aStoreReferrerResolvesDeterministically() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer( + "utm_source=cn1_invite&utm_medium=referral&cn1_invite=ABC123", + 1700000000L, 1700000060L); + } + }); + + Invites.checkForInvite(); + + // The deterministic path posts a claim carrying the code itself, and + // never the statistical match. + boolean sawClaim = false; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + String url = implementation.getQueuedRequests().get(i).getUrl(); + assertTrue(!url.endsWith("/invites/match"), + "a device with a store referrer must not be fingerprinted"); + if (url.endsWith("/invites/claim")) { + sawClaim = true; + assertTrue(implementation.getQueuedRequests().get(i) + .getRequestBody().contains("ABC123")); + } + } + assertTrue(sawClaim, "expected a deterministic claim"); + } + + @FormTest + void noStoreReferrerFallsBackToTheStatisticalMatch() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + } + }); + + Invites.checkForInvite(); + + boolean sawMatch = false; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + if (implementation.getQueuedRequests().get(i).getUrl().endsWith("/invites/match")) { + sawMatch = true; + String body = implementation.getQueuedRequests().get(i).getRequestBody(); + // The server reads the address off the socket; the client must + // never try to enumerate it. + assertTrue(!body.contains("\"ip\""), body); + assertTrue(body.contains("osVersion"), body); + assertTrue(body.contains("deviceModel"), body); + } + } + assertTrue(sawMatch, "expected the statistical match as the fallback"); + } + + @FormTest + void aSecondLinkDoesNotRewriteTheFirstTouchCohort() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("FIRST", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + Invites.handleUrl("https://cloud.codenameone.com/i/SECOND"); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a); + assertEquals("FIRST", a.getCode(), + "rewriting the cohort mid-stream makes lifetime value unjoinable"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java new file mode 100644 index 00000000000..4fe909c5569 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java @@ -0,0 +1,170 @@ +/* + * 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; + +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.share.ShareResult; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InviteFunnelEventsTest extends UITestBase { + + @AfterEach + void cleanUp() { + InviteTestSupport.tearDown(); + } + + @FormTest + void sharedToReportsInviteSharedWithTheRealTarget() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invite invite = Invites.create(InviteRequest.create().campaign("spring").build()); + recorder.clear(); + + Invites.reportShareResult(invite, ShareResult.sharedTo("com.whatsapp")); + + AnalyticsEvent e = recorder.first("invite_shared"); + assertNotNull(e, "expected invite_shared, saw " + recorder.names()); + assertEquals(Invites.CATEGORY, e.getCategory()); + assertEquals(invite.getCode(), e.getParameters().get("invite_code")); + assertEquals("com.whatsapp", e.getParameters().get("target")); + } + + @FormTest + void aDismissedSheetNeverReportsAShare() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invite invite = Invites.create(InviteRequest.create().build()); + recorder.clear(); + + Invites.reportShareResult(invite, ShareResult.dismissed()); + + // This is the difference between a measured funnel and an assumed one: + // "created but abandoned" has to be distinguishable from "sent". + assertEquals(0, recorder.count("invite_shared")); + assertNotNull(recorder.first("invite_share_dismissed")); + } + + @FormTest + void anUnknownTargetOmitsTheParameterRatherThanInventingOne() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invite invite = Invites.create(InviteRequest.create().build()); + recorder.clear(); + + // Older Android and the web share api cannot say where it went. + Invites.reportShareResult(invite, ShareResult.sharedTo(null)); + + AnalyticsEvent e = recorder.first("invite_shared"); + assertNotNull(e); + assertFalse(e.getParameters().containsKey("target"), + "an unknown target must be absent, not a placeholder"); + } + + @FormTest + void conversionIsANoOpUntilSomethingIsAttributed() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + recorder.clear(); + + Invites.conversion("signup", 9.99, "USD"); + + assertEquals(0, recorder.count("invite_converted")); + } + + @FormTest + void conversionCarriesValueAndCurrencyOnceAttributed() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + recorder.clear(); + + Invites.conversion("signup", 9.99, "USD"); + + AnalyticsEvent e = recorder.first("invite_converted"); + assertNotNull(e, "expected invite_converted, saw " + recorder.names()); + assertEquals(Invites.CATEGORY, e.getCategory()); + assertEquals("ABC123", e.getParameters().get("invite_code")); + assertEquals("spring", e.getParameters().get("campaign")); + assertEquals("signup", e.getParameters().get("action")); + assertEquals("USD", e.getParameters().get("currency")); + assertNotNull(e.getParameters().get("value")); + } + + @FormTest + void aDeferredResolutionReportsAnInstallRatherThanAnOpen() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + recorder.clear(); + + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + assertNotNull(recorder.first("invite_install")); + assertEquals(0, recorder.count("invite_opened")); + assertEquals(Invites.MATCH_REFERRER, + recorder.first("invite_install").getParameters().get("match")); + } + + @FormTest + void aDirectOpenReportsAnOpenRatherThanAnInstall() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + recorder.clear(); + + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_DIRECT, false); + + assertNotNull(recorder.first("invite_opened")); + assertEquals(0, recorder.count("invite_install")); + } + + @FormTest + void everyFunnelEventUsesTheReferralCategory() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invite invite = Invites.create(InviteRequest.create().build()); + Invites.reportShareResult(invite, ShareResult.sharedTo("com.whatsapp")); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + Invites.conversion("signup"); + + assertTrue(recorder.events().size() >= 4, recorder.names().toString()); + for (AnalyticsEvent e : recorder.events()) { + assertEquals(Invites.CATEGORY, e.getCategory(), + e.getName() + " is not under the referral category"); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java new file mode 100644 index 00000000000..6980195825e --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java @@ -0,0 +1,159 @@ +/* + * 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; + +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.io.ConnectionRequest; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InviteMintTest extends UITestBase { + + @AfterEach + void cleanUp() { + InviteTestSupport.tearDown(); + } + + @FormTest + void createReturnsUsableInviteWithNoNetwork() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create() + .campaign("spring").channel("sms").build()); + + // The whole point of minting on the device: an invite is shareable the + // instant it is asked for, on a plane, in a queue, at a conference. + assertNotNull(invite); + assertNotNull(invite.getCode()); + assertTrue(invite.getUrl().startsWith("https://"), invite.getUrl()); + assertTrue(invite.getUrl().endsWith("/i/" + invite.getCode()), invite.getUrl()); + assertEquals("spring", invite.getCampaign()); + assertEquals("sms", invite.getChannel()); + assertFalse(invite.isRegistered()); + } + + @FormTest + void codesAreUrlSafeAndDistinct() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Set seen = new HashSet(); + for (int i = 0; i < 200; i++) { + String code = Invites.create(InviteRequest.create().build()).getCode(); + assertTrue(seen.add(code), "duplicate code " + code); + for (int j = 0; j < code.length(); j++) { + char c = code.charAt(j); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '-' || c == '_'; + assertTrue(ok, "code is not url safe: " + code); + } + } + } + + @FormTest + void createQueuesRegistrationCarryingTheCodeAndIdentity() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create() + .campaign("spring").channel("sms").payload("room-42").build()); + + List requests = implementation.getQueuedRequests(); + assertEquals(1, requests.size(), "expected exactly one registration post"); + ConnectionRequest r = requests.get(0); + assertTrue(r.getUrl().endsWith("/api/v2/analytics/invites"), r.getUrl()); + assertTrue(r.isPost()); + String body = r.getRequestBody(); + assertTrue(body.contains(invite.getCode()), body); + // mapToJson pretty prints, so compare with the whitespace removed + // rather than pinning the exact rendering. + String compact = body.replace(" ", "").replace("\n", ""); + assertTrue(compact.contains("\"campaign\":\"spring\""), body); + assertTrue(compact.contains("\"channel\":\"sms\""), body); + assertTrue(compact.contains("\"payload\":\"room-42\""), body); + assertTrue(compact.contains("\"clientId\":"), body); + } + + @FormTest + void createEmitsInviteCreatedUnderTheReferralCategory() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create().campaign("spring").build()); + + AnalyticsEvent e = recorder.first("invite_created"); + assertNotNull(e, "expected invite_created, saw " + recorder.names()); + assertEquals(Invites.CATEGORY, e.getCategory()); + assertEquals(invite.getCode(), e.getParameters().get("invite_code")); + assertEquals("spring", e.getParameters().get("campaign")); + } + + @FormTest + void linkBaseIsOverridable() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.setLinkBase("https://links.example.com/"); + + Invite invite = Invites.create(InviteRequest.create().build()); + + // The trailing slash on the configured base must not survive into the + // url, or every link would carry a double slash. + assertEquals("https://links.example.com/i/" + invite.getCode(), invite.getUrl()); + } + + @FormTest + void builderRejectsBadInputAtTheCallTheDeveloperCanSee() { + StringBuilder tooLong = new StringBuilder(); + for (int i = 0; i <= InviteRequest.MAX_PAYLOAD_LENGTH; i++) { + tooLong.append('x'); + } + final String payload = tooLong.toString(); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + new org.junit.jupiter.api.function.Executable() { + public void execute() { + InviteRequest.create().payload(payload).build(); + } + }); + assertTrue(e.getMessage().contains("payload"), e.getMessage()); + + IllegalArgumentException e2 = assertThrows(IllegalArgumentException.class, + new org.junit.jupiter.api.function.Executable() { + public void execute() { + InviteRequest.create().campaign("spring sale!").build(); + } + }); + assertTrue(e2.getMessage().contains("campaign"), e2.getMessage()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java new file mode 100644 index 00000000000..c07741294db --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -0,0 +1,77 @@ +/* + * 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; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; +import com.codename1.io.Preferences; + +/** + * Puts the static invite state back to a fresh-install baseline. Invites keeps + * process-wide state on purpose -- it models one device -- so every case has to + * start from a known point or the order tests run in changes their result. + */ +final class InviteTestSupport { + private InviteTestSupport() { + } + + static RecordingProvider freshInstall() { + Analytics.clearProviders(); + Analytics.clearDimensions(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.setInviteListener(null); + Invites.setLinkBase(null); + Invites.setReattribution(false); + Invites.setAttributionWindow(Invites.DEFAULT_ATTRIBUTION_WINDOW); + Invites.registerInstallReferrerSource(null); + Invites.reset(); + Preferences.delete(Invites.PREF_SLUG); + Preferences.delete(Invites.PREF_CONSUMED_ARG); + // reset() clears the records; clearProviders() above dropped the + // provider Invites registers, and the next facade call re-adds it. + RecordingProvider recorder = new RecordingProvider(); + Analytics.addProvider(recorder); + return recorder; + } + + static void tearDown() { + Invites.setInviteListener(null); + Invites.registerInstallReferrerSource(null); + Invites.reset(); + Analytics.clearProviders(); + Analytics.clearDimensions(); + Analytics.setConsent(null); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Preferences.delete(Invites.PREF_SLUG); + Preferences.delete(Invites.PREF_CONSUMED_ARG); + } + + /** A canned server answer, in the shape the link service returns. */ + static String resolvedJson(String code, String campaign, String channel) { + return "{\"resolved\":true,\"code\":\"" + code + "\",\"campaign\":\"" + + campaign + "\",\"channel\":\"" + channel + + "\",\"score\":100,\"clickTs\":1700000000000}"; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java new file mode 100644 index 00000000000..caf5f415be7 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -0,0 +1,100 @@ +/* + * 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; + +import com.codename1.io.Preferences; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class InviteUrlParsingTest extends UITestBase { + + @AfterEach + void cleanUp() { + InviteTestSupport.tearDown(); + } + + @FormTest + void recognisesTheSluggedAndBareLinkForms() { + InviteTestSupport.freshInstall(); + assertEquals("ABC123", + Invites.extractCode("https://cloud.codenameone.com/i/acme/ABC123")); + // The slug is remembered so later invites mint the precise form, which + // is what keeps two enrolled apps on one device from claiming each + // other's links. + assertEquals("acme", Preferences.get(Invites.PREF_SLUG, "")); + + InviteTestSupport.freshInstall(); + assertEquals("ABC123", + Invites.extractCode("https://cloud.codenameone.com/i/ABC123")); + } + + @FormTest + void ignoresAForeignHostEvenWhenThePathMatches() { + InviteTestSupport.freshInstall(); + assertNull(Invites.extractCode("https://evil.example.com/i/acme/ABC123")); + // A prefix of our host is not our host. Matching on startsWith here + // would accept a look-alike domain. + assertNull(Invites.extractCode("https://cloud.codenameone.com.evil.test/i/ABC123")); + assertNull(Invites.extractCode("https://staging.cloud.codenameone.com/i/ABC123")); + } + + @FormTest + void hostComparisonIsCaseInsensitiveWithoutCaseFolding() { + InviteTestSupport.freshInstall(); + assertEquals("ABC123", + Invites.extractCode("https://CLOUD.CodenameOne.COM/i/ABC123")); + } + + @FormTest + void readsTheCodeOutOfAReferrerQueryString() { + InviteTestSupport.freshInstall(); + assertEquals("ABC123", Invites.codeFromQuery( + "utm_source=cn1_invite&utm_medium=referral&cn1_invite=ABC123")); + assertEquals("ABC123", Invites.codeFromQuery("cn1_invite=ABC123")); + assertNull(Invites.codeFromQuery("utm_source=cn1_invite&utm_medium=referral")); + assertNull(Invites.codeFromQuery("")); + assertNull(Invites.codeFromQuery(null)); + } + + @FormTest + void theReferrerKeyIsMatchedExactlyAndNeverCaseFolded() { + InviteTestSupport.freshInstall(); + // String.toLowerCase is locale sensitive and has no root-locale + // overload in this runtime, so under a Turkish default locale the 'i' + // in "invite" folds to a dotless i and a folded comparison silently + // stops matching. The key is therefore compared with equals, and a + // differently cased key is simply not our key. + assertNull(Invites.codeFromQuery("CN1_INVITE=ABC123")); + assertNull(Invites.codeFromQuery("Cn1_Invite=ABC123")); + } + + @FormTest + void valueIsSplitOnTheFirstEqualsOnly() { + InviteTestSupport.freshInstall(); + assertEquals("a=b", Invites.codeFromQuery("cn1_invite=a%3Db")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/RecordingProvider.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/RecordingProvider.java new file mode 100644 index 00000000000..b4229b01026 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/RecordingProvider.java @@ -0,0 +1,88 @@ +/* + * 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; + +import com.codename1.analytics.AbstractAnalyticsProvider; +import com.codename1.analytics.AnalyticsCapability; +import com.codename1.analytics.AnalyticsEvent; +import java.util.ArrayList; +import java.util.List; + +/** + * Captures whole AnalyticsEvent objects rather than the rendered strings + * LoggingAnalyticsProvider keeps, so a test can assert on the category and on + * individual parameter values. + */ +class RecordingProvider extends AbstractAnalyticsProvider { + private final List events = new ArrayList(); + + @Override + public String getName() { + return "recording"; + } + + @Override + public void trackEvent(AnalyticsEvent event) { + events.add(event); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return true; + } + + List events() { + return events; + } + + void clear() { + events.clear(); + } + + AnalyticsEvent first(String name) { + for (AnalyticsEvent e : events) { + if (name.equals(e.getName())) { + return e; + } + } + return null; + } + + int count(String name) { + int n = 0; + for (AnalyticsEvent e : events) { + if (name.equals(e.getName())) { + n++; + } + } + return n; + } + + List names() { + List out = new ArrayList(); + for (AnalyticsEvent e : events) { + out.add(e.getName()); + } + return out; + } +} From 5b44d6bea89a37c955a6c5a028b21c659005f55e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:01:15 +0300 Subject: [PATCH 03/70] Invite attribution: declare the build hints the link plumbing will read 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. --- .../build/shared/BuildHintsAndroid.java | 23 +++++++++++++++++++ .../build/shared/BuildHintsGeneral.java | 11 +++++++++ .../codename1/build/shared/BuildHintsIos.java | 12 ++++++++++ 3 files changed, 46 insertions(+) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 0b9e29a3239..9645eeb6ef0 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -1232,6 +1232,29 @@ static void register(List h) { .platform("android") .doc("Allows adding an intent filter to the main android activity")); + h.add(new Hint("android.invite.appLinks") + .group(HintGroup.ANDROID) + .type(HintType.BOOLEAN) + .def("true") + .platform("android") + .doc("Whether the build injects the `android:autoVerify` intent filter for the " + + "invite link domain into the main activity. Set it to `false` only when " + + "declaring the filter yourself through `android.xintent_filter`; with no " + + "filter at all an invite link opens the browser instead of the app.")); + + h.add(new Hint("android.invite.signingFingerprint") + .group(HintGroup.ANDROID) + .type(HintType.STRING_LIST) + .separator(",") + .platform("android") + .doc("Comma separated SHA-256 signing certificate fingerprints, in colon separated " + + "hex, enrolled in the shared `assetlinks.json` alongside the one derived " + + "from the build's keystore. Apps distributed through Play App Signing " + + "must add the app signing certificate fingerprint from the Play Console " + + "here: Google re-signs the app, so the upload key the build holds is not " + + "the certificate Android verifies against, and App Links verification " + + "fails silently on every Play install without it.")); + h.add(new Hint("android.xlargeScreens") .group(HintGroup.ANDROID) .type(HintType.BOOLEAN) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java index 0735e8d6afe..bd16e6abb16 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java @@ -52,6 +52,17 @@ static void register(List h) { .doc("Whether video calls are offered, on both platforms. `ios.call.video` and " + "`android.call.video` override it per platform.")); + h.add(new Hint("invite.domain") + .group(HintGroup.GENERAL) + .type(HintType.STRING) + .def("cloud.codenameone.com") + .platform("general") + .doc("The host that serves Codename One invite links " + + "(`https:///i//`). Changing it points the generated " + + "Android App Link intent filter and the iOS associated domain at a " + + "different link service; the matching `apple-app-site-association` and " + + "`assetlinks.json` must be served from that host.")); + h.add(new Hint("KeepScreenOn") .group(HintGroup.GENERAL) .type(HintType.BOOLEAN) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 2bb6c9baed6..ded5926d606 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -209,6 +209,18 @@ static void register(List h) { .doc("Objective-C code that can be injected into the iOS callback method (message) " + "`applicationDidEnterBackground`.")); + h.add(new Hint("ios.invite.universalLinks") + .group(HintGroup.IOS) + .type(HintType.BOOLEAN) + .def("true") + .platform("ios") + .doc("Whether the build appends `applinks:` for the invite link domain to " + + "`ios.associatedDomains` and requests the matching " + + "`com.apple.developer.associated-domains` entitlement. Set it to `false` " + + "to manage both yourself. The provisioning profile must grant the " + + "Associated Domains capability either way, or invite links silently " + + "open Safari instead of the app.")); + h.add(new Hint("ios.associatedDomains") .group(HintGroup.IOS) .type(HintType.STRING) From 8f64c9299ffb6b87a68dae672554f32084e8ed00 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:10:16 +0300 Subject: [PATCH 04/70] Invite attribution: make an invite link open the app, on both platforms 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 , 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: 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. --- .../builders/AndroidGradleBuilder.java | 50 ++++++++ .../com/codename1/builders/IPhoneBuilder.java | 65 ++++++++++ .../builders/InviteManifestFragments.java | 120 ++++++++++++++++++ .../builders/InviteAssociatedDomainTest.java | 85 +++++++++++++ .../builders/InviteManifestFragmentsTest.java | 110 ++++++++++++++++ 5 files changed, 430 insertions(+) create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index f5b03dba893..11e91f5c04e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -808,6 +808,10 @@ private java.util.Set foldInCallAndVpnLibraryUsage( /// Whether the app referenced com.codename1.vpn.tunnel. private boolean usesCustomTunnel; + /// Whether the app referenced the invite attribution API, and therefore + /// needs the App Links filter that lets an invite link open it. + private boolean usesInvites; + private boolean integrateMoPub = false; private static final boolean isMac; @@ -2302,6 +2306,13 @@ public void usesClass(String cls) { if (cls.indexOf("com/codename1/vpn/tunnel/") == 0) { usesCustomTunnel = true; } + // Both entry points, because an app can reference either + // one alone: the button without the facade, or the facade + // without the button. + if (cls.indexOf("com/codename1/analytics/invite/") == 0 + || "com/codename1/components/InviteButton".equals(cls)) { + usesInvites = true; + } if (cls.indexOf("com/codename1/nearby/ranging/") == 0) { usesNearbyRanging = true; } @@ -2948,6 +2959,45 @@ public void usesClassMethod(String cls, String method) { } } + // The App Links filter that lets an invite link open the app instead + // of the browser (com.codename1.analytics.invite). + // + // AFTER the class scan, beside the call fragments, because the flag it + // reads is set BY that scan -- the same ordering the tunnel block below + // documents the hard way. + // + // Appended to android.xintent_filter rather than emitted at a new + // manifest site. That hint is already rendered inside the main + // , and rendered again into the wear companion manifest, so + // one append reaches both and cannot drift. + if (usesInvites && "true".equals(request.getArg("android.invite.appLinks", "true"))) { + String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + String existingFilter = request.getArg("android.xintent_filter", ""); + String withAppLinks = + InviteManifestFragments.injectAppLinks(existingFilter, inviteHost); + if (!withAppLinks.equals(existingFilter)) { + debug("Invite attribution: adding the App Links filter for " + inviteHost); + request.putArgument("android.xintent_filter", withAppLinks); + } + // launchMode decides whether a link reaching an app that is + // already running is delivered to it at all. singleTop (the + // default) and singleTask both route through onNewIntent; + // "standard" starts a SECOND activity and a second lifecycle, and + // the invite is simply lost. Refused rather than warned: a warning + // in a build log is exactly the thing nobody reads, and the + // symptom on the device is a feature that silently never fires. + String launchMode = request.getArg("android.activity.launchMode", "singleTop"); + if ("standard".equals(launchMode)) { + throw new BuildException("This app uses invite attribution " + + "(com.codename1.analytics.invite), which needs an invite link to reach " + + "the running activity, but android.activity.launchMode is \"standard\". " + + "A link then starts a second activity instead of being delivered to the " + + "running one, and the invite is lost. Use singleTop (the default) or " + + "singleTask, or set android.invite.appLinks=false and handle the link " + + "yourself."); + } + } + // A packet tunnel the app implements (com.codename1.vpn.tunnel). // // AFTER the class scan, beside the call fragments, because the flag diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index af84a03d582..3f1a257539d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1351,6 +1351,11 @@ private java.util.Set foldInCallAndVpnLibraryUsage( // other hand would fail its codesigning for a capability it never asked for. private boolean usesContinuitySync; + // Set when the app references com.codename1.analytics.invite (or the + // InviteButton that fronts it). Gates the associated domain and the + // entitlement that let an invite link open the app instead of Safari. + private boolean usesInvites; + // Set when the app references com.codename1.documents. Gates the CN1_USE_DOCUMENTS native // define, the CN1Documents file provider extension and the app group that lets the two // processes meet. @@ -1492,6 +1497,28 @@ public void cleanup() { /// Records a boolean CarPlay entitlement (e.g. com.apple.developer.carplay-audio) unless the /// project already set it explicitly, mirroring how the App Attest / Apple Sign-In entitlements /// are injected. The downstream entitlements generator emits these as <true/>. + /// Whether a comma delimited ios.associatedDomains value already declares + /// `domain`. + /// + /// Compared element by element after trimming, never as a substring: an + /// existing `applinks:staging.cloud.codenameone.com` CONTAINS + /// `applinks:cloud.codenameone.com` is false, but the reverse containment + /// -- an existing entry for a longer host reading as the shorter one -- + /// is exactly the mistake the surfaces url-scheme code documents, and the + /// same shape of bug applies here. + static boolean declaresAssociatedDomain(String existing, String domain) { + if (existing == null || domain == null) { + return false; + } + StringTokenizer tok = new StringTokenizer(existing, ","); + while (tok.hasMoreTokens()) { + if (tok.nextToken().trim().equals(domain)) { + return true; + } + } + return false; + } + private void putCarPlayEntitlement(BuildRequest request, String key) { if (request.getArg("ios.entitlements." + key, null) == null) { request.putArgument("ios.entitlements." + key, "true"); @@ -2733,6 +2760,14 @@ public void usesClass(String cls) { if (!usesDocuments && cls.indexOf("com/codename1/documents/") == 0) { usesDocuments = true; } + // Invite attribution (com.codename1.analytics.invite). Both entry points, + // because an app can reference either alone: the button without the facade, + // or the facade without the button. + if (!usesInvites + && (cls.indexOf("com/codename1/analytics/invite/") == 0 + || "com/codename1/components/InviteButton".equals(cls))) { + usesInvites = true; + } // State restoration and continuity (com.codename1.continuity.*). Gated on // actual usage so the CN1_USE_CONTINUITY natives and the NSUserActivityTypes // entry are only added for apps that hand work between devices. @@ -4345,6 +4380,36 @@ public void usesClassMethod(String cls, String method) { File CodenameOne_GLViewController_m = new File(buildinRes, "CodenameOne_GLViewController.m"); replaceInFile(CodenameOne_GLViewController_m, "BOOL vkbAlwaysOpen = NO;", "BOOL vkbAlwaysOpen = YES;"); } + // Invite attribution needs an invite link to open the app rather + // than Safari, which on iOS means a universal link, which means the + // invite host has to be an associated domain. + // + // This MUST run before the block below. That block's only test is + // whether ios.associatedDomains is non-null, and it is what + // uncomments CN1_HANDLE_UNIVERSAL_LINKS in + // CodenameOne_GLViewController.h. Appending one line later would + // leave the define commented out: the entitlement would be present, + // application:continueUserActivity:restorationHandler: would not be + // compiled in, and every invite link would silently open the + // browser. + // + // The matching com.apple.developer.associated-domains entitlement + // is derived from this same hint by the entitlements generator, so + // it is not written separately here -- doing that would risk a + // duplicate key, which fails codesigning. + if (usesInvites + && "true".equals(request.getArg("ios.invite.universalLinks", "true"))) { + String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + String want = "applinks:" + inviteHost; + String existingDomains = request.getArg("ios.associatedDomains", ""); + if (!declaresAssociatedDomain(existingDomains, want)) { + String merged = existingDomains.trim().length() == 0 + ? want : existingDomains + "," + want; + debug("Invite attribution: adding the associated domain " + want); + request.putArgument("ios.associatedDomains", merged); + } + } + if (request.getArg("ios.associatedDomains", null) != null) { // If the user has provided the ios.associatedDomains build hint, then we will need to // enable handling for these events. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java new file mode 100644 index 00000000000..f398222fa4f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java @@ -0,0 +1,120 @@ +/* + * 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.builders; + +/** + * Builds the App Links intent filter injected into the main activity when the + * bytecode scanner detects usage of {@code com.codename1.analytics.invite}. + * + *

Extracted into a pure static helper for the reason + * {@link CallManifestFragments} gives: the nuances are unit-testable here and + * the BuildDaemon copy stays trivially diffable -- keep this file in sync + * with {@code com.codename1.build.daemon.InviteManifestFragments}.

+ * + *

Why this is here rather than in {@code PlatformFeatureCatalog}: the + * catalog has no manifest-fragment channel at all. It can name a permission, a + * feature or a meta-data pair, and an {@code } carrying + * {@code android:autoVerify} is none of those.

+ * + *

The fragment is appended to the {@code android.xintent_filter} build hint + * rather than emitted at a new manifest site. That hint is already rendered + * inside the main {@code }, and it is rendered a second time into + * the wear companion manifest -- so appending to it reaches both, and cannot + * drift the way two separate injection sites would.

+ */ +final class InviteManifestFragments { + + /** + * Bumped when the fragment changes, so a build log names which version + * produced a manifest. + */ + static final int FRAGMENT_VERSION = 1; + + private InviteManifestFragments() { + } + + /** + * Returns {@code existing} with the invite App Links filter appended, or + * {@code existing} unchanged when the host is already declared. + * + * @param existing the current {@code android.xintent_filter} value + * @param host the invite link host, for example + * {@code cloud.codenameone.com} + * @return the value to put back on the hint + */ + static String injectAppLinks(String existing, String host) { + String current = existing == null ? "" : existing; + if (host == null || host.length() == 0) { + return current; + } + if (declaresHost(current, host)) { + return current; + } + return current + filter(host); + } + + /** + * Whether {@code existing} already declares an intent filter for + * {@code host}. + * + *

Matched as a whole quoted attribute rather than as a substring. A + * plain {@code contains(host)} would read + * {@code android:host="staging.cloud.codenameone.com"} as already + * declaring {@code cloud.codenameone.com}, and the developer's staging + * filter would suppress the production one.

+ * + * @param existing the current hint value + * @param host the host to look for + * @return true when the host is already declared + */ + static boolean declaresHost(String existing, String host) { + if (existing == null || host == null) { + return false; + } + return existing.indexOf("android:host=\"" + host + "\"") >= 0; + } + + /** + * The filter itself. + * + *

{@code android:autoVerify="true"} is what makes Android open the app + * instead of the browser without a disambiguation dialog. It only takes + * effect if the host serves an {@code assetlinks.json} naming this + * application's package and the SHA-256 of the certificate the installed + * APK is really signed with -- which, under Play App Signing, is Google's + * key and not the upload key.

+ * + * @param host the invite link host + * @return the intent filter XML + */ + static String filter(String host) { + return "\n \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n"; + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java new file mode 100644 index 00000000000..a42d1e2e567 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java @@ -0,0 +1,85 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the element-wise comparison used before appending the invite + * associated domain to {@code ios.associatedDomains}. + * + *

The value is a comma delimited list, and a substring test on it gets the + * wrong answer in both directions: a developer's entry for a longer host would + * read as declaring ours, and ours would read as declaring theirs.

+ */ +class InviteAssociatedDomainTest { + + private static final String WANT = "applinks:cloud.codenameone.com"; + + @Test + void anExactElementIsRecognised() { + assertTrue(IPhoneBuilder.declaresAssociatedDomain(WANT, WANT)); + assertTrue(IPhoneBuilder.declaresAssociatedDomain( + "webcredentials:example.com," + WANT, WANT)); + assertTrue(IPhoneBuilder.declaresAssociatedDomain( + WANT + ",applinks:example.com", WANT)); + } + + @Test + void whitespaceAroundAnElementDoesNotHideIt() { + assertTrue(IPhoneBuilder.declaresAssociatedDomain( + "applinks:example.com, " + WANT + " ", WANT)); + } + + @Test + void aLongerHostDoesNotReadAsOurs() { + // The trap: applinks:staging.cloud.codenameone.com must not suppress + // the production domain, or a developer with a staging entry ships an + // app whose invite links open Safari. + assertFalse(IPhoneBuilder.declaresAssociatedDomain( + "applinks:staging.cloud.codenameone.com", WANT)); + } + + @Test + void aShorterHostDoesNotReadAsOursEither() { + assertFalse(IPhoneBuilder.declaresAssociatedDomain( + "applinks:codenameone.com", WANT)); + } + + @Test + void aDifferentPrefixForTheSameHostIsNotTheSameDeclaration() { + // webcredentials: on our host grants password autofill, not links. + assertFalse(IPhoneBuilder.declaresAssociatedDomain( + "webcredentials:cloud.codenameone.com", WANT)); + } + + @Test + void emptyAndNullAreHandled() { + assertFalse(IPhoneBuilder.declaresAssociatedDomain("", WANT)); + assertFalse(IPhoneBuilder.declaresAssociatedDomain(null, WANT)); + assertFalse(IPhoneBuilder.declaresAssociatedDomain(WANT, null)); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java new file mode 100644 index 00000000000..efec377b326 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java @@ -0,0 +1,110 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the App Links intent filter injected for the + * {@code com.codename1.analytics.invite} API, and in particular the + * quote-delimited duplicate suppression that keeps a developer's own filter + * for a DIFFERENT host from suppressing ours. + */ +class InviteManifestFragmentsTest { + + private static final String HOST = "cloud.codenameone.com"; + + @Test + void filterCarriesAutoVerifyAndTheInvitePath() { + String out = InviteManifestFragments.injectAppLinks("", HOST); + // autoVerify is what makes Android open the app rather than showing a + // disambiguation dialog, and it is the whole point of the filter. + assertTrue(out.contains("android:autoVerify=\"true\""), out); + assertTrue(out.contains("android:name=\"android.intent.action.VIEW\""), out); + assertTrue(out.contains("android:name=\"android.intent.category.BROWSABLE\""), out); + assertTrue(out.contains("android:scheme=\"https\""), out); + assertTrue(out.contains("android:host=\"" + HOST + "\""), out); + assertTrue(out.contains("android:pathPrefix=\"/i/\""), out); + } + + @Test + void aDevelopersOwnFilterIsPreservedAndOursIsAppended() { + String existing = "" + + "" + + "" + + ""; + String out = InviteManifestFragments.injectAppLinks(existing, HOST); + assertTrue(out.startsWith(existing), "the developer's filter must survive verbatim"); + assertTrue(out.contains("android:host=\"" + HOST + "\""), out); + } + + @Test + void anAlreadyDeclaredHostIsNotDeclaredTwice() { + String existing = InviteManifestFragments.injectAppLinks("", HOST); + String out = InviteManifestFragments.injectAppLinks(existing, HOST); + assertEquals(existing, out, "the host was declared a second time"); + } + + @Test + void aDifferentHostThatContainsOursDoesNotSuppressIt() { + // The trap this test exists for: a plain contains(host) check reads + // android:host="staging.cloud.codenameone.com" as already declaring + // cloud.codenameone.com, so a developer with a staging filter would + // silently ship without the production one and every invite link would + // open the browser. + String existing = "" + + "" + + ""; + assertFalse(InviteManifestFragments.declaresHost(existing, HOST), + "a longer host must not read as ours"); + String out = InviteManifestFragments.injectAppLinks(existing, HOST); + assertTrue(out.contains("android:host=\"" + HOST + "\""), + "the production filter was suppressed by a staging one"); + } + + @Test + void aHostThatIsAPrefixOfOursDoesNotSuppressItEither() { + String existing = ""; + assertFalse(InviteManifestFragments.declaresHost(existing, HOST)); + } + + @Test + void anEmptyHostInjectsNothing() { + assertEquals("", InviteManifestFragments.injectAppLinks("", "")); + assertEquals("", InviteManifestFragments.injectAppLinks("", null)); + assertEquals("x", InviteManifestFragments.injectAppLinks("x", null)); + } + + @Test + void aCustomHostIsHonoured() { + String out = InviteManifestFragments.injectAppLinks("", "links.example.com"); + assertTrue(out.contains("android:host=\"links.example.com\""), out); + assertFalse(out.contains(HOST), out); + } +} From 76b058b35220ba92ae98e08c65741a6f98817b9b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:22:18 +0300 Subject: [PATCH 05/70] Invite attribution: read the Play Install Referrer, and drop a floor that was never there Adds the deterministic Android path. The link service puts cn1_invite= 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. --- .../analytics/invite/package-info.java | 7 +- .../referrer/AndroidInstallReferrer.java | 159 ++++++++++++++++++ maven/android/pom.xml | 10 ++ .../builders/AndroidGradleBuilder.java | 34 ++++ .../build/shared/PlatformFeatureCatalog.java | 30 ++-- .../shared/PlatformFeatureCatalogTest.java | 9 +- 6 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/package-info.java b/CodenameOne/src/com/codename1/analytics/invite/package-info.java index d0b1827c0eb..af9d25cd49c 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/package-info.java +++ b/CodenameOne/src/com/codename1/analytics/invite/package-info.java @@ -58,7 +58,8 @@ /// /// This package is deliberately separate from /// {@link com.codename1.analytics}. The Android half of the attribution links -/// the Play Install Referrer library, which raises the application's minimum -/// API level, and the build only does that for applications that actually -/// reference this package. +/// the Play Install Referrer library and declares the permission that binds to +/// it, and the build only does that for applications that actually reference +/// this package -- an application that merely reports analytics carries +/// neither. package com.codename1.analytics.invite; diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java new file mode 100644 index 00000000000..8125fecbaa5 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -0,0 +1,159 @@ +/* + * 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.impl.android.referrer; + +import android.content.Context; +import com.android.installreferrer.api.InstallReferrerClient; +import com.android.installreferrer.api.InstallReferrerStateListener; +import com.android.installreferrer.api.ReferrerDetails; +import com.codename1.analytics.invite.InstallReferrerCallback; +import com.codename1.analytics.invite.InstallReferrerSource; +import com.codename1.analytics.invite.Invites; +import com.codename1.impl.android.AndroidNativeUtil; +import com.codename1.io.Log; +import com.codename1.io.Preferences; + +/// Reads the Play Store install referrer, which is the deterministic half of +/// invite attribution on Android: the invite code makes the whole round trip +/// through the store and comes back verbatim, so nothing has to be matched or +/// guessed. +/// +/// Compiled inside the generated application rather than into the port jar, +/// because it names `com.android.installreferrer`, which +/// `PlatformFeatureCatalog` adds only for an application that referenced the +/// invite package. `AndroidGradleBuilder` deletes this package for every other +/// application, and splices the registration call for the ones that kept it. +public class AndroidInstallReferrer implements InstallReferrerSource { + // The referrer is retained by Google for the life of the install and + // returns the same answer every time, so one successful read is enough + // and the flag is what stops a service bind on every launch. + private static final String PREF_ATTEMPTED = "cn1$invite$referrerAttempted"; + + private boolean retried; + + @Override + public boolean isSupported() { + return AndroidNativeUtil.getContext() != null + && !Preferences.get(PREF_ATTEMPTED, false); + } + + @Override + public void requestReferrer(InstallReferrerCallback callback) { + Context context = AndroidNativeUtil.getContext(); + if (context == null) { + callback.onUnavailable(Invites.REASON_UNSUPPORTED); + return; + } + try { + connect(InstallReferrerClient.newBuilder(context).build(), callback); + } catch (Throwable t) { + // A missing or broken store client must read as "no referral", + // never as a crash: the application still works, it simply has no + // invite behind it. + Log.e(t); + finish(callback, Invites.REASON_UNSUPPORTED); + } + } + + private void connect(final InstallReferrerClient client, + final InstallReferrerCallback callback) { + client.startConnection(new InstallReferrerStateListener() { + @Override + public void onInstallReferrerSetupFinished(int responseCode) { + try { + switch (responseCode) { + case InstallReferrerClient.InstallReferrerResponse.OK: + deliver(client, callback); + break; + case InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE: + // Transient. Exactly one retry: a loop here would + // bind the service repeatedly on a device that is + // never going to answer. + if (!retried) { + retried = true; + close(client); + requestReferrer(callback); + return; + } + finish(callback, Invites.REASON_NO_MATCH); + break; + default: + // FEATURE_NOT_SUPPORTED is the ordinary answer on a + // device with no Play Store -- a sideload, an + // emulator without store services, another vendor's + // store. Terminal, and not an error. + finish(callback, Invites.REASON_UNSUPPORTED); + break; + } + } catch (Throwable t) { + Log.e(t); + finish(callback, Invites.REASON_UNSUPPORTED); + } finally { + close(client); + } + } + + @Override + public void onInstallReferrerServiceDisconnected() { + // Deliberately not reconnecting. The one retry above is the + // whole allowance; an automatic reconnect here is how a + // background service bind loop starts. + } + }); + } + + private void deliver(InstallReferrerClient client, InstallReferrerCallback callback) { + String referrer = ""; + long clickSeconds = 0; + long beginSeconds = 0; + try { + ReferrerDetails details = client.getInstallReferrer(); + if (details != null) { + referrer = details.getInstallReferrer(); + clickSeconds = details.getReferrerClickTimestampSeconds(); + beginSeconds = details.getInstallBeginTimestampSeconds(); + } + } catch (Throwable t) { + Log.e(t); + } + Preferences.set(PREF_ATTEMPTED, true); + if (referrer == null || referrer.length() == 0) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + return; + } + callback.onReferrer(referrer, clickSeconds, beginSeconds); + } + + private void finish(InstallReferrerCallback callback, String reason) { + Preferences.set(PREF_ATTEMPTED, true); + callback.onUnavailable(reason); + } + + private void close(InstallReferrerClient client) { + try { + client.endConnection(); + } catch (Throwable t) { + Log.e(t); + } + } +} diff --git a/maven/android/pom.xml b/maven/android/pom.xml index adabb3acc30..fabd5551db3 100644 --- a/maven/android/pom.xml +++ b/maven/android/pom.xml @@ -124,6 +124,16 @@ the builder deletes whichever halves the app did not ask for. --> com/codename1/impl/android/nearby/** + + com/codename1/impl/android/referrer/** + entry in nbproject/project.properties and in maven/android/pom.xml. + + The referrer package is excluded for the ordinary reason: it names + com.android.installreferrer, a Play library the generated + application pulls in through PlatformFeatureCatalog and the port + jar never has. + + THE LIST LIVES IN THREE FILES: here, in + nbproject/project.properties, and in maven/android/pom.xml. The + Maven build stays green with only its own copy updated, so adding + a package to one file is not adding it. --> + excludes="com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java"> diff --git a/Ports/Android/nbproject/project.properties b/Ports/Android/nbproject/project.properties index e4d6e4ef090..2b0451f5250 100644 --- a/Ports/Android/nbproject/project.properties +++ b/Ports/Android/nbproject/project.properties @@ -32,8 +32,11 @@ endorsed.classpath= # The BiometricPrompt backend is the same arrangement against a newer SDK # rather than a missing dependency: android.hardware.biometrics is API 28 to 30 # and the cn1-binaries android.jar is API 27. -# Mirrors the maven-compiler excludes in maven/android/pom.xml. -excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java +# Mirrors the maven-compiler excludes in maven/android/pom.xml AND the +# excludes= attribute in build.xml. The list lives in three files; the Maven +# build is green with only one of them updated, so adding a package here alone +# is not adding it. +excludes=com/codename1/impl/android/ar/**,com/codename1/impl/android/ai/**,com/codename1/impl/android/cipher/**,com/codename1/impl/android/nearby/**,com/codename1/impl/android/referrer/**,com/codename1/impl/android/biometrics/**,com/codename1/impl/android/BillingSupport.java file.reference.android-support-v7-appcompat.jar=../../../cn1-binaries/android/android-support-v7-appcompat.jar file.reference.android-support-v7-cardview.jar=../../../cn1-binaries/android/android-support-v7-cardview.jar file.reference.android-support-v7-gridlayout.jar=../../../cn1-binaries/android/android-support-v7-gridlayout.jar From 5a71e814dbb44010723c86131863799d6f7c71e3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:56:51 +0300 Subject: [PATCH 16/70] Invites: six answers that reached nobody, or reached the wrong conclusion 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) --- .../codename1/analytics/invite/Invites.java | 60 ++++++++++++- .../referrer/AndroidInstallReferrer.java | 16 ++++ .../builders/InviteManifestFragments.java | 9 ++ .../builders/InviteManifestFragmentsTest.java | 30 ++++++- .../invite/InviteResilienceTest.java | 85 +++++++++++++++++++ 5 files changed, 194 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 14efbeeb5a8..24fcbe95418 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -209,6 +209,11 @@ public final class Invites { private static int state = STATE_NONE; private static boolean stateLoaded; private static boolean deliveredThisRun; + + // A terminal "no invite" answer reached before a listener was registered. + // Held for the run rather than persisted: the state itself is durable, and + // a later launch reaches this answer again through the ordinary path. + private static String undelivered; private static boolean deferredStarted; // Bumped whenever the identity or the permission behind an outstanding @@ -460,7 +465,12 @@ public static boolean handleUrl(String url) { // this a refused user who opened an invite link still had a profile // persisted -- by a different route to the one that was fixed. if (explicitlyDenied()) { + // Told, not silently dropped. checkForInvite() records the url as + // consumed and skips the deferred path after this, so this is the + // only chance the listener gets for this install -- and a + // registered one heard nothing at all. markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED); + notifyUnavailable(REASON_CONSENT_DENIED); return true; } if (getState() == STATE_RESOLVED && !reattribution) { @@ -520,6 +530,7 @@ private static void loadAttribution() { // private and test-only: the whole point of the durable records is that // the answer survives a relaunch, and nothing else can check that. static void forgetLoadedState() { + undelivered = null; stateLoaded = false; attributionLoaded = false; resolved = null; @@ -717,6 +728,14 @@ public static void flush() { // until the next cold start. The persisted attempt counter still // bounds the retries. if (getState() == STATE_PENDING) { + // The retry supersedes whatever the last attempt left outstanding. + // Without the bump, a fingerprint answer still on the wire from the + // earlier attempt passes the guard and can land AFTER the retried + // referrer resolved exactly -- overwriting the exact attribution + // with a statistical one. Not hypothetical on an application with + // more than one NetworkManager thread, where the two are genuinely + // concurrent. + lookupEpoch++; deferredStarted = false; beginDeferred(); } @@ -743,6 +762,7 @@ public static void reset() { stateLoaded = true; deliveredThisRun = false; deferredStarted = false; + undelivered = null; unacknowledged.clear(); } @@ -769,10 +789,18 @@ static void eraseInternal() { // Package private: called from the provider when consent changes. static void onConsentChanged(boolean allowed) { if (allowed) { - if (getState() == STATE_PENDING) { + // STATE_DECLINED belongs here too. It is the state a refusal during + // a pending lookup leaves behind, and its marker is reopenable + // precisely because granting consent afterwards is a real answer -- + // but nothing restarted the lookup until the application happened + // to call checkForInvite() again, by which time the attribution + // window may well have closed. beginDeferred() reopens the marker + // itself, so calling it is the whole fix. + int s = getState(); + if (s == STATE_PENDING || s == STATE_DECLINED) { deferredStarted = false; beginDeferred(); - } else if (getState() == STATE_RESOLVED) { + } else if (s == STATE_RESOLVED) { // Re-granting restores the dimensions from the record we kept, // without re-reporting the install or telling the app again. InviteAttribution a = getAttribution(); @@ -1635,6 +1663,15 @@ private static void deliverPending() { if (listener == null || deliveredThisRun) { return; } + // Taken into a local and cleared unconditionally, rather than + // null-checked in place and cleared inside the branch. Same reason as + // notifyUnavailable above. + String held = undelivered; + undelivered = null; + if (held != null) { + notifyUnavailable(held); + return; + } Map r = InviteStore.read(InviteStore.ATTRIBUTION); if (r == null || InviteStore.getBoolean(r, "delivered", false)) { return; @@ -1654,12 +1691,27 @@ private static void deliverPending() { } private static void notifyUnavailable(String reason) { - if (listener == null || deliveredThisRun) { + if (deliveredThisRun) { + return; + } + // Read into a local before the branch, for the same reason loadState() + // does: null-checking a static field and then assigning one inside the + // branch is the shape PMD reads as an unsynchronized lazy singleton, + // and the answer is not a lock -- this facade runs on the EDT. + InviteListener target = listener; + if (target == null) { + // Held, not dropped. The answer is terminal, so no later lookup + // will produce it again, and setInviteListener() only replays a + // resolved attribution -- so an application that answers the + // deferred question before registering its listener got neither + // callback for the whole install, against the documented promise + // that an early answer is delivered on registration. + undelivered = reason; return; } deliveredThisRun = true; try { - listener.attributionUnavailable(reason); + target.attributionUnavailable(reason); } catch (Throwable t) { Log.e(t); } diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index e223106975e..798405f07f9 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -137,6 +137,7 @@ private void deliver(InstallReferrerClient client, InstallReferrerCallback callb String referrer = ""; long clickSeconds = 0; long beginSeconds = 0; + boolean threw = false; try { ReferrerDetails details = client.getInstallReferrer(); if (details != null) { @@ -145,8 +146,23 @@ private void deliver(InstallReferrerClient client, InstallReferrerCallback callb beginSeconds = details.getInstallBeginTimestampSeconds(); } } catch (Throwable t) { + // The connection came up and the read failed -- a RemoteException + // from the service, most often. That is the same kind of transient + // failure as a bind that never succeeded, and it is not evidence + // about whether a referrer exists. + threw = true; Log.e(t); } + if (threw) { + // Deliberately NOT recorded as attempted. Burning the once-only + // flag here makes isSupported() false for ever, so a later + // Invites.flush() skips the deterministic path entirely and a + // statistical no-match settles the install as organic -- for a + // referrer that was there all along and simply could not be read + // this once. + callback.onUnavailable(Invites.REASON_NO_MATCH); + return; + } Preferences.set(PREF_ATTEMPTED, true); if (referrer == null || referrer.length() == 0) { callback.onUnavailable(Invites.REASON_NO_MATCH); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java index 34b38344a9e..9606aca748f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java @@ -166,6 +166,15 @@ private static boolean coversInviteLinks(String block, String host, String slug) if (!declaresHost(block, host)) { return false; } + // The scheme too, in this same filter. An http-only filter on the + // invite host and path claims nothing about https, and the links this + // builder generates are https -- so treating it as coverage suppressed + // the generated filter and left every invite link opening the browser. + // A filter that names no scheme at all matches none of ours: Android + // requires a scheme before a host is considered. + if (block.indexOf("android:scheme=\"https\"") < 0) { + return false; + } String prefix = pathPrefix(slug); // Any pathPrefix that is a prefix of ours covers our links: a filter // on "/i/" accepts "/i//". The reverse is not true, and a diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java index a7bc6cd5ded..4983e01132c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java @@ -113,7 +113,7 @@ void anUnrelatedPathOnTheSameHostDoesNotSuppressTheInviteFilter() { @Test void aBroaderPrefixOnTheSameHostDoesCoverTheInviteLinks() { - String existing = ""; assertTrue(InviteManifestFragments.declaresInviteLinks(existing, HOST, "acme"), "/i/ accepts /i/acme/ and needs no second filter"); @@ -122,7 +122,7 @@ void aBroaderPrefixOnTheSameHostDoesCoverTheInviteLinks() { @Test void anotherAppsSlugDoesNotCoverOurs() { - String existing = ""; assertFalse(InviteManifestFragments.declaresInviteLinks(existing, HOST, "acme")); } @@ -196,4 +196,30 @@ void aCustomHostIsHonoured() { assertTrue(out.contains("android:host=\"links.example.com\""), out); assertFalse(out.contains(HOST), out); } + + @Test + void anHttpOnlyFilterDoesNotCoverOurHttpsLinks() { + // The links this builder generates are https. An http-only filter on + // the same host and path claims nothing about them, and treating it as + // coverage suppressed the generated filter -- so every invite link kept + // opening the browser, which is the exact symptom the filter exists to + // prevent. + String existing = "" + + ""; + assertFalse(InviteManifestFragments.declaresInviteLinks(existing, HOST, "acme")); + String out = InviteManifestFragments.injectAppLinks(existing, HOST, "acme"); + assertTrue(out.contains("android:scheme=\"https\""), + "the https filter was suppressed by an http-only one"); + } + + @Test + void aFilterNamingNoSchemeAtAllCoversNothing() { + // Android requires a scheme before a host is considered, so a data + // element without one matches no url whatsoever. + String existing = "" + + ""; + assertFalse(InviteManifestFragments.declaresInviteLinks(existing, HOST, "acme")); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index d49514e86de..69e1cb28922 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -360,4 +360,89 @@ void aRefusalIsDurableAndIsReopenedByALaterGrant() { assertEquals(Invites.STATE_PENDING, Invites.getState(), "granting consent afterwards did not reopen the lookup"); } + + @Test + @EdtTest + void flushSupersedesWhateverTheLastAttemptLeftOutstanding() { + // Retrying under the same epoch let a fingerprint answer from the + // earlier attempt land after the retried referrer resolved exactly, and + // overwrite it. Genuinely concurrent on an app with more than one + // NetworkManager thread. + Invites.checkForInvite(); + int stale = Invites.currentLookupEpochForTest(); + + Invites.flush(); + Invites.handleResolution(InviteTestSupport.resolvedJson("EXACT1", "c1", "sms"), + Invites.MATCH_REFERRER, true); + + Invites.handleResolution(InviteTestSupport.resolvedJson("GUESS", "c2", "unknown"), + Invites.MATCH_FINGERPRINT, true, stale); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a); + assertEquals("EXACT1", a.getCode(), + "a stale statistical answer overwrote the retried exact one"); + } + + @Test + @EdtTest + void grantingConsentResumesADeclinedLookupWithoutWaitingForTheApp() { + // The refusal leaves STATE_DECLINED with a reopenable marker, and + // nothing restarted the lookup until the application happened to call + // checkForInvite() again -- by which time the attribution window may + // have closed. + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + + Analytics.setConsent(AnalyticsConsent.granted()); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "granting consent did not resume the declined lookup"); + } + + @Test + @EdtTest + void anUnavailableAnswerReachedBeforeRegistrationIsStillDelivered() { + // The answer is terminal, so no later lookup produces it again, and + // setInviteListener only replays a resolved attribution -- so an app + // that answered the deferred question before registering its listener + // got neither callback for the entire install. + Invites.setAttributionWindow(0); + Invites.checkForInvite(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + assertEquals(Invites.REASON_UNSUPPORTED, told[0], + "the answer reached before registration was dropped"); + } + + @Test + @EdtTest + void aRefusedDirectLinkTellsTheListener() { + // checkForInvite marks the url consumed and skips the deferred path + // after this, so it is the only chance the listener gets -- and a + // registered one heard nothing at all. + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/CODE1"); + assertEquals(Invites.REASON_CONSENT_DENIED, told[0], + "a refused direct link told the listener nothing"); + } } From 37706e660615075466b64bee7aef7b7d4cf6991c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:11:16 +0300 Subject: [PATCH 17/70] Invites: an erasure that does not depend on who is registered 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) --- .../com/codename1/analytics/Analytics.java | 40 ++++++++++++++++++ .../analytics/invite/InviteStore.java | 14 +++++++ .../codename1/analytics/invite/Invites.java | 32 ++++++++++++++- .../invite/InviteConsentAndErasureTest.java | 25 +++++++++++ .../invite/InviteResilienceTest.java | 41 +++++++++++++++++++ 5 files changed, 150 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index e907cc077d1..f54c290a913 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -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; @@ -463,6 +464,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 @@ -471,6 +479,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(PROVIDERS); } AnalyticsContext ctx = context(); @@ -484,6 +501,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> it = DIMENSIONS.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry 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 diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index 4af26b53620..c55400cecfa 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -151,7 +151,21 @@ static List readOutbox() { /// read-only store. The caller has to know: an entry that never reached /// the outbox carries the campaign, channel, payload and preview of a link /// that has already been handed out, and nothing can reconstruct it later. + // Package private test seam. A full or read-only store cannot be produced + // from a test, and the paths that only run when the write fails are the + // ones most worth pinning -- they are what happens when the durable queue + // is gone. + private static boolean failNextWrite; + + static void failNextOutboxWriteForTest() { + failNextWrite = true; + } + static boolean writeOutbox(List entries) { + if (failNextWrite) { + failNextWrite = false; + return false; + } try { Storage s = Storage.getInstance(); if (s == null) { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 24fcbe95418..130adb89fcb 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -275,8 +275,24 @@ public static Invite create(InviteRequest request) { // registered with everything it carries; if it does not, nothing // is worse than the alternative. There is deliberately no retry, // because the queue that would drive one is the thing that failed. - unacknowledged.add(invite.getCode()); - postRegistration(pendingRegistration); + if (allowed()) { + unacknowledged.add(invite.getCode()); + postRegistration(pendingRegistration); + } else { + // Nothing leaves the device without consent, and that outranks + // saving the registration. drainOutbox() carries the same guard; + // 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, because the queue that would have + // held it is the thing that failed. That is the correct trade: + // the link still attributes through the click, and only the + // campaign, channel and preview metadata go with it. + Log.p("invite: the registration outbox could not be written and consent " + + "does not permit sending, so this invite's campaign and preview " + + "metadata are lost", Log.WARNING); + } } Map p = new HashMap(); p.put("invite_code", code); @@ -1206,6 +1222,18 @@ public void run() { fallBackToMatch(false); return; } + // Persisted BEFORE the claim goes out. The source + // has already burned its once-only flag by the time + // this runs, so if the claim fails -- a timeout, a + // dead network -- the exact code exists nowhere but + // this callback, and the next flush() falls back to + // a statistical match for an answer we had read + // exactly. Written into the pending record, the + // ordinary retry path resends it. + Map pending = pendingRecord(); + pending.put("code", code); + pending.remove("referrerRetry"); + InviteStore.write(InviteStore.PENDING, pending); claim(code, "install_referrer", rawReferrer == null ? "" : rawReferrer, MATCH_REFERRER, true); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index 6499f0b3c2f..651dba3c650 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -329,4 +329,29 @@ private void assertNoProfileHeld(String message) { assertFalse(record.containsKey(key), message + " (held " + key + ")"); } } + + @FormTest + void anErasureClearsTheReferralDimensionsEvenWithNoProviderRegistered() { + // 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 + // reserved dimensions on the new id, and the next provider the app + // registered transmitted them. An erasure cannot depend on who happens + // to be registered when it runs. + InviteTestSupport.freshInstall(); + Invites.handleResolution(InviteTestSupport.resolvedJson("CODE1", "spring", "sms"), + Invites.MATCH_DIRECT, false); + assertNotNull(Analytics.getDimensions().get("cn1_campaign")); + Analytics.setDimension("plan", "pro"); + + Analytics.clearProviders(); + Analytics.resetClientId(); + + assertNull(Analytics.getDimensions().get("cn1_campaign"), + "an erasure left the referral dimensions on the new client id"); + assertNull(Analytics.getDimensions().get("cn1_invite_code")); + assertEquals("pro", Analytics.getDimensions().get("plan"), + "the application's own dimension must survive an erasure"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 69e1cb28922..a1d4b074fef 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -25,6 +25,7 @@ import com.codename1.analytics.Analytics; import com.codename1.analytics.AnalyticsConsent; import com.codename1.junit.EdtTest; +import com.codename1.junit.FormTest; import java.io.ByteArrayInputStream; import java.io.IOException; import com.codename1.junit.UITestBase; @@ -445,4 +446,44 @@ public void attributionUnavailable(String reason) { assertEquals(Invites.REASON_CONSENT_DENIED, told[0], "a refused direct link told the listener nothing"); } + + @Test + @EdtTest + void theReferrerCodeIsPersistedBeforeTheClaimGoesOut() { + // The source has already burned its once-only flag by the time the + // callback runs, so a claim that fails leaves the exact code nowhere + // but that callback and the next flush() falls back to a statistical + // match for an answer that had been read exactly. + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=EXACT9", 0L, 0L); + } + }); + Invites.checkForInvite(); + + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending, "the pending record was not kept at all"); + assertEquals("EXACT9", InviteStore.get(pending, "code", null), + "the exact referrer code was not persisted before the claim"); + } + + @FormTest + void aFailedOutboxWriteStillTransmitsNothingWithoutConsent() { + // drainOutbox carries the consent guard and this fallback had none, so + // a storage failure was the one way an undecided user's client id and + // invite metadata reached the server. + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + InviteStore.failNextOutboxWriteForTest(); + + Invite invite = Invites.create(InviteRequest.create().campaign("launch").build()); + assertNotNull(invite, "minting is offline and must still work"); + assertEquals(0, implementation.getQueuedRequests().size(), + "a registration was transmitted before consent was given"); + } } From 81c7aa83e25dcd0b9f75c5b13fa6147655339466 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:43:59 +0300 Subject: [PATCH 18/70] Invites: five consequences of the last two rounds 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) --- .../invite/InviteAttributionProvider.java | 16 +- .../codename1/analytics/invite/Invites.java | 102 ++++++++++- .../invite/InviteResilienceTest.java | 165 ++++++++++++++++++ .../analytics/invite/InviteTestSupport.java | 1 + 4 files changed, 274 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index 0358e659713..2a3aa624850 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -27,6 +27,7 @@ import com.codename1.analytics.AnalyticsCapability; import com.codename1.analytics.AnalyticsConsent; import com.codename1.analytics.AnalyticsContext; +import com.codename1.analytics.ConsentMode; import com.codename1.io.Preferences; // The seam that lets invite attribution honour an erasure request and a @@ -93,10 +94,21 @@ public void onConsentChanged(AnalyticsConsent consent) { // Analytics.getConsent() returns null until there is a real choice on // record, so ask it instead of believing the argument. AnalyticsConsent recorded = Analytics.getConsent(); - if (recorded == null) { + if (recorded != null) { + Invites.onConsentChanged(recorded.isAnalytics()); return; } - Invites.onConsentChanged(recorded.isAnalytics()); + // No choice on record. Under OPT_IN that means the prompt has not been + // answered and there is nothing to act on -- returning is the whole + // point of the paragraph above. Under OPT_OUT it means something else + // entirely: the mode's implicit allow is back in force, which is a real + // transition. Clearing an explicit denial there resumed ordinary + // analytics while a declined invite lookup stayed stopped and a + // resolved attribution's dimensions stayed cleared, so the two + // disagreed about the same user. + if (Analytics.getConsentMode() == ConsentMode.OPT_OUT) { + Invites.onConsentChanged(true); + } } @Override diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 130adb89fcb..58593d6b1b1 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -216,6 +216,25 @@ public final class Invites { private static String undelivered; private static boolean deferredStarted; + // When the last claim or match was issued. flush() restarts only once this + // has aged out: retrying a request that is still outstanding spends an + // attempt without a failure having been observed, and the attempt budget is + // what decides when the install is settled. + // + // A timestamp rather than a boolean, because the requests are fail-silent + // -- a failure produces no callback at all -- so a flag cleared by a + // response would never be cleared for exactly the request a retry exists + // for, and flush() could wedge for the rest of the process. + private static long lookupIssuedAt; + + // Package private so a test can retry without waiting. + static long lookupRetryDelay = 30000L; + + private static boolean lookupInFlight() { + return lookupIssuedAt != 0 + && System.currentTimeMillis() - lookupIssuedAt < lookupRetryDelay; + } + // Bumped whenever the identity or the permission behind an outstanding // lookup changes -- an erasure, or consent being withdrawn. A response // carries the epoch it was issued under and is dropped if it no longer @@ -547,6 +566,7 @@ private static void loadAttribution() { // the answer survives a relaunch, and nothing else can check that. static void forgetLoadedState() { undelivered = null; + lookupIssuedAt = 0; stateLoaded = false; attributionLoaded = false; resolved = null; @@ -743,7 +763,13 @@ public static void flush() { // drain registrations and silently leave the attribution unresolved // until the next cold start. The persisted attempt counter still // bounds the retries. - if (getState() == STATE_PENDING) { + if (getState() == STATE_PENDING && !lookupInFlight()) { + // Only when nothing is outstanding. Restarting on every call burned + // the attempt budget without a single observed failure -- 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. + // // The retry supersedes whatever the last attempt left outstanding. // Without the bump, a fingerprint answer still on the wire from the // earlier attempt passes the guard and can land AFTER the retried @@ -778,6 +804,7 @@ public static void reset() { stateLoaded = true; deliveredThisRun = false; deferredStarted = false; + lookupIssuedAt = 0; undelivered = null; unacknowledged.clear(); } @@ -814,6 +841,11 @@ static void onConsentChanged(boolean allowed) { // itself, so calling it is the whole fix. int s = getState(); if (s == STATE_PENDING || s == STATE_DECLINED) { + // The refusal may have been recorded for a listener that had + // not registered yet. It is not the answer any more, and + // leaving it held meant a lookup that went on to resolve was + // reported to that listener as unavailable instead. + undelivered = null; deferredStarted = false; beginDeferred(); } else if (s == STATE_RESOLVED) { @@ -1083,6 +1115,13 @@ private static void markTerminal(int terminalState, String reason) { Map done = new LinkedHashMap(); done.put("state", String.valueOf(terminalState)); if (reason != null) { + // Recorded on the marker, not only in memory. The listener contract + // is "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 and the unavailable answer had + // nothing, so an application whose deferred question was settled + // before it registered its listener, in a process that then exited, + // got neither callback for the life of the install. done.put("reason", reason); } InviteStore.write(InviteStore.PENDING, done); @@ -1090,6 +1129,31 @@ private static void markTerminal(int terminalState, String reason) { stateLoaded = true; } + // The terminal answer this install reached, if it was never delivered. + // Null once a listener has heard it, so the contract's "exactly one per + // install" holds across launches exactly as it does for a resolved + // attribution. + private static String undeliveredFromMarker() { + int s = getState(); + if (s != STATE_NONE_FOUND && s != STATE_DECLINED) { + return null; + } + Map marker = InviteStore.read(InviteStore.PENDING); + if (marker == null || InviteStore.getBoolean(marker, "delivered", false)) { + return null; + } + return InviteStore.get(marker, "reason", REASON_NO_MATCH); + } + + private static void markUnavailableDelivered() { + Map marker = InviteStore.read(InviteStore.PENDING); + if (marker == null) { + return; + } + marker.put("delivered", "true"); + InviteStore.write(InviteStore.PENDING, marker); + } + private static Map pendingRecord() { Map pending = InviteStore.read(InviteStore.PENDING); if (pending != null) { @@ -1207,6 +1271,13 @@ private static boolean safeSupported(InstallReferrerSource source) { } private static void requestReferrer(InstallReferrerSource source) { + // The epoch this read was ISSUED under, captured here. The platform + // callback below can run long after a direct link arrived and advanced + // the epoch, and reading the field at callback time made the old read + // inherit the new epoch -- so it passed the guard and could overwrite + // the direct attribution. Incrementing the epoch cannot invalidate a + // callback that does not remember which epoch it belongs to. + final int issued = lookupEpoch; try { source.requestReferrer(new InstallReferrerCallback() { @Override @@ -1215,6 +1286,9 @@ public void onReferrer(final String rawReferrer, final long clickSeconds, onEdt(new Runnable() { @Override public void run() { + if (issued != lookupEpoch) { + return; + } String code = codeFromQuery(rawReferrer); if (code == null) { // The referrer was read and carries no invite. @@ -1246,6 +1320,9 @@ public void onUnavailable(final String reason) { onEdt(new Runnable() { @Override public void run() { + if (issued != lookupEpoch) { + return; + } // REASON_UNSUPPORTED is the store saying this // device will never have a referrer. Anything else // is transient -- the store was busy, the bind @@ -1328,6 +1405,7 @@ private static void requestMatch(Map pending) { body.put("locale", InviteStore.get(pending, "locale", "")); body.put("screenWidth", Integer.valueOf(InviteStore.getInt(pending, "screenWidth", 0))); body.put("screenHeight", Integer.valueOf(InviteStore.getInt(pending, "screenHeight", 0))); + lookupIssuedAt = System.currentTimeMillis(); post(getLinkBase() + PATH_MATCH, body, MATCH_FINGERPRINT, true); } @@ -1344,6 +1422,7 @@ private static void claim(String code, String source, String rawReferrer, body.put("code", code); body.put("source", source); body.put("rawReferrer", rawReferrer == null ? "" : rawReferrer); + lookupIssuedAt = System.currentTimeMillis(); post(getLinkBase() + PATH_CLAIM, body, matchType, deferred); } @@ -1483,6 +1562,9 @@ static void handleResolution(String payload, String matchType, boolean deferred) } static void handleResolution(String payload, String matchType, boolean deferred, int epoch) { + if (epoch == lookupEpoch) { + lookupIssuedAt = 0; + } // A response that was already on the wire when consent was withdrawn or // the identity was erased must not be acted on. Both of those delete the // pending record and clear the dimensions; resolving anyway would write @@ -1693,9 +1775,14 @@ private static void deliverPending() { } // Taken into a local and cleared unconditionally, rather than // null-checked in place and cleared inside the branch. Same reason as - // notifyUnavailable above. + // notifyUnavailable above. The durable half comes second: an answer + // reached in an earlier process left nothing in memory, and the + // contract says the answer is remembered. String held = undelivered; undelivered = null; + if (held == null) { + held = undeliveredFromMarker(); + } if (held != null) { notifyUnavailable(held); return; @@ -1728,16 +1815,15 @@ private static void notifyUnavailable(String reason) { // and the answer is not a lock -- this facade runs on the EDT. InviteListener target = listener; if (target == null) { - // Held, not dropped. The answer is terminal, so no later lookup - // will produce it again, and setInviteListener() only replays a - // resolved attribution -- so an application that answers the - // deferred question before registering its listener got neither - // callback for the whole install, against the documented promise - // that an early answer is delivered on registration. + // Held for this run, and durably by the marker markTerminal wrote. + // Either way it is not dropped: the answer is terminal, so no later + // lookup produces it again, and setInviteListener() would otherwise + // replay only a resolved attribution. undelivered = reason; return; } deliveredThisRun = true; + markUnavailableDelivered(); try { target.attributionUnavailable(reason); } catch (Throwable t) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index a1d4b074fef..4b3f06a196a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -24,6 +24,7 @@ import com.codename1.analytics.Analytics; import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; import com.codename1.junit.EdtTest; import com.codename1.junit.FormTest; import java.io.ByteArrayInputStream; @@ -39,7 +40,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -372,6 +375,9 @@ void flushSupersedesWhateverTheLastAttemptLeftOutstanding() { Invites.checkForInvite(); int stale = Invites.currentLookupEpochForTest(); + // The first attempt has aged out; flush() deliberately does nothing + // while one is still outstanding, which is the sibling case below. + Invites.lookupRetryDelay = 0L; Invites.flush(); Invites.handleResolution(InviteTestSupport.resolvedJson("EXACT1", "c1", "sms"), Invites.MATCH_REFERRER, true); @@ -486,4 +492,163 @@ void aFailedOutboxWriteStillTransmitsNothingWithoutConsent() { assertEquals(0, implementation.getQueuedRequests().size(), "a registration was transmitted before consent was given"); } + + @Test + @EdtTest + void flushDoesNotSpendAnAttemptOnALookupThatIsStillOutstanding() { + // create() calls flush() unconditionally, so minting five invites in a + // row exhausted MAX_ATTEMPTS without a single observed failure -- and + // the last one settled the install as terminal while its own answer was + // still on the wire. + Invites.checkForInvite(); + Map after = InviteStore.read(InviteStore.PENDING); + int attempts = InviteStore.getInt(after, "attempts", 0); + + for (int i = 0; i < 8; i++) { + Invites.flush(); + } + + Map now = InviteStore.read(InviteStore.PENDING); + assertEquals(attempts, InviteStore.getInt(now, "attempts", 0), + "flush() spent the attempt budget on a lookup that had not failed"); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "the install was settled while its answer was still on the wire"); + } + + @Test + @EdtTest + void aReferrerCallbackThatArrivesAfterADirectLinkIsIgnored() { + // The platform callback used to read lookupEpoch at callback time, so + // an outstanding referrer 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. + final InstallReferrerCallback[] held = new InstallReferrerCallback[1]; + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + held[0] = callback; + } + }); + Invites.checkForInvite(); + assertNotNull(held[0], "the referrer read was never issued"); + + Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT2"); + Invites.handleResolution(InviteTestSupport.resolvedJson("DIRECT2", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + // The referrer finally answers, carrying a different code. It must be + // dropped where it arrives -- before it writes its code into the + // pending record and issues a claim -- because once a claim goes out + // under the current epoch nothing downstream can tell it apart from a + // legitimate one. + held[0].onReferrer("utm_source=cn1_invite&cn1_invite=LATE2", 0L, 0L); + + Map pending = InviteStore.read(InviteStore.PENDING); + String recorded = pending == null ? null : InviteStore.get(pending, "code", null); + assertNotEquals("LATE2", recorded, + "a stale referrer callback wrote its code and issued a claim"); + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a); + assertEquals("DIRECT2", a.getCode()); + } + + @Test + @EdtTest + void aRefusalHeldForALateListenerIsDiscardedWhenTheLookupResumes() { + // The refusal was recorded for a listener that had not registered yet. + // Once consent is granted it is not the answer any more, and leaving it + // held reported a lookup that went on to resolve as unavailable. + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.handleResolution(InviteTestSupport.resolvedJson("RESOLVED1", "c1", "sms"), + Invites.MATCH_FINGERPRINT, true); + + final String[] unavailable = new String[1]; + final InviteAttribution[] received = new InviteAttribution[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0] = a; + } + + public void attributionUnavailable(String reason) { + unavailable[0] = reason; + } + }); + assertNull(unavailable[0], "a stale refusal was reported over a resolved attribution"); + assertNotNull(received[0], "the resolved attribution was never delivered"); + } + + @Test + @EdtTest + void anUnavailableAnswerSurvivesTheProcessThatReachedIt() { + // The contract is "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. + Invites.setAttributionWindow(0); + Invites.checkForInvite(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + Invites.forgetLoadedState(); + + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + assertEquals(Invites.REASON_UNSUPPORTED, told[0], + "the answer did not survive the process that reached it"); + } + + @Test + @EdtTest + void anAnswerAlreadyDeliveredIsNotDeliveredAgainOnALaterLaunch() { + // The other half of the same contract: exactly one, not one per launch. + Invites.setAttributionWindow(0); + Invites.checkForInvite(); + final int[] told = new int[1]; + InviteListener l = new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }; + Invites.setInviteListener(l); + assertEquals(1, told[0]); + + Invites.forgetLoadedState(); + Invites.setInviteListener(l); + assertEquals(1, told[0], "the answer was delivered twice across launches"); + } + + @Test + @EdtTest + void clearingAnExplicitDenialUnderOptOutResumesAttribution() { + // Under OPT_OUT a null recorded choice is the mode's implicit allow, not + // an unanswered prompt. Ignoring it resumed ordinary analytics while a + // declined invite lookup stayed stopped, so the two disagreed about the + // same user. + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + + Analytics.setConsent(null); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "clearing the denial under opt-out did not resume the lookup"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index c07741294db..3e9b7b6b649 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -46,6 +46,7 @@ static RecordingProvider freshInstall() { Invites.setReattribution(false); Invites.setAttributionWindow(Invites.DEFAULT_ATTRIBUTION_WINDOW); Invites.registerInstallReferrerSource(null); + Invites.lookupRetryDelay = 30000L; Invites.reset(); Preferences.delete(Invites.PREF_SLUG); Preferences.delete(Invites.PREF_CONSUMED_ARG); From 15460753d44e853e1b61efcb930946d3183b1c42 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:22:45 +0300 Subject: [PATCH 19/70] Invites: re-attribution no longer contradicts the answer it already gave 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) --- .../codename1/analytics/invite/Invites.java | 22 ++++- .../invite/InviteResilienceTest.java | 95 +++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 58593d6b1b1..1637924af83 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1232,7 +1232,7 @@ private static void beginDeferred() { Map pending = pendingRecord(); long expires = InviteStore.getLong(pending, "expiresAt", 0); if (expires > 0 && System.currentTimeMillis() > expires) { - markTerminal(); + markTerminal(REASON_EXPIRED); notifyUnavailable(REASON_EXPIRED); return; } @@ -1606,6 +1606,16 @@ static void handleResolution(String payload, String matchType, boolean deferred, setState(STATE_PENDING); return; } + if (getAttribution() != null) { + // A re-attribution claim that found nothing. The earlier + // attribution is still the answer for this install, so + // nothing is terminal here -- terminalizing it 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. + return; + } // Terminal, and it has to be durable. Deleting the record is // not enough: loadState() reads an absent record as STATE_NONE, // so the next launch built a fresh profile and asked again, and @@ -1675,7 +1685,15 @@ private static void resolve(InviteAttribution a, String confidence) { InviteStore.put(record, "params", JSONParser.mapToJson(new LinkedHashMap(a.getParameters()))); } - record.put("delivered", "false"); + // Carried across from the record this one replaces. Re-attribution + // rewrites the attribution but not the fact that the listener has + // already been told about this install, and the contract is exactly one + // callback per install -- resetting the flag delivered inviteReceived() + // a second time, immediately if the first had happened in an earlier + // process and on the next launch if it had happened in this one. + Map previous = InviteStore.read(InviteStore.ATTRIBUTION); + record.put("delivered", + String.valueOf(InviteStore.getBoolean(previous, "delivered", false))); // Storage was chosen over Preferences precisely because it reports a // failed write, so the result is checked. Deleting the pending record // after a failed write would leave neither an attribution nor any retry diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 4b3f06a196a..a2bb713ac11 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -651,4 +651,99 @@ void clearingAnExplicitDenialUnderOptOutResumesAttribution() { assertEquals(Invites.STATE_PENDING, Invites.getState(), "clearing the denial under opt-out did not resume the lookup"); } + + @Test + @EdtTest + void aReattributionThatFindsNothingLeavesTheEarlierAnswerStanding() { + // The earlier attribution is still the answer for this install, so a + // failed replacement is not terminal. Terminalizing it 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. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST1", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_DIRECT, false); + + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "a failed re-attribution terminalized an attributed install"); + assertEquals(0, told[0], "the listener was told the opposite of what it had heard"); + assertNotNull(Invites.getAttribution()); + } + + @Test + @EdtTest + void areplacementAttributionIsNotDeliveredASecondTime() { + // Re-attribution rewrites the attribution but not the fact that the + // listener has already been told about this install, and the contract is + // exactly one callback per install. + final int[] received = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0]++; + } + + public void attributionUnavailable(String reason) { + } + }); + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST2", "c1", "sms"), + Invites.MATCH_DIRECT, false); + assertEquals(1, received[0]); + + Invites.setReattribution(true); + Invites.handleResolution(InviteTestSupport.resolvedJson("SECOND2", "c2", "email"), + Invites.MATCH_DIRECT, false); + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0]++; + } + + public void attributionUnavailable(String reason) { + } + }); + + assertEquals(1, received[0], "the replacement was delivered as a second callback"); + } + + @Test + @EdtTest + void anExpiredWindowReportsExpiryToALateListener() { + // The expiry marker carried no reason, so after the process that + // reached it exited, a late listener was told REASON_NO_MATCH -- the + // marker's default -- instead of what actually happened. + Invites.checkForInvite(); + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending); + pending.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); + InviteStore.write(InviteStore.PENDING, pending); + + Invites.forgetLoadedState(); + Invites.checkForInvite(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + Invites.forgetLoadedState(); + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + assertEquals(Invites.REASON_EXPIRED, told[0], + "the late listener was told the wrong reason"); + } } From 7a767caae3f8ba6a747437ad612401dbe93a3d54 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:50:56 +0300 Subject: [PATCH 20/70] Invites: three more that the last two rounds' fixes opened 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) --- .../codename1/analytics/invite/Invites.java | 54 ++++++++++- .../invite/InviteResilienceTest.java | 91 +++++++++++++++++++ 2 files changed, 141 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 1637924af83..dcba82b0f9f 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -214,6 +214,12 @@ public final class Invites { // Held for the run rather than persisted: the state itself is durable, and // a later launch reaches this answer again through the ordinary path. private static String undelivered; + + // Set when a terminal marker that had already been delivered is reopened, + // so the attribution the resumed lookup writes inherits that fact rather + // than announcing itself a second time. Carried onto the pending record as + // soon as one exists, which is what makes it survive the process. + private static boolean reopenedAlreadyDelivered; private static boolean deferredStarted; // When the last claim or match was issued. flush() restarts only once this @@ -806,6 +812,7 @@ public static void reset() { deferredStarted = false; lookupIssuedAt = 0; undelivered = null; + reopenedAlreadyDelivered = false; unacknowledged.clear(); } @@ -1165,6 +1172,10 @@ private static Map pendingRecord() { pending.put("expiresAt", String.valueOf(now + attributionWindow)); pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); + if (reopenedAlreadyDelivered) { + pending.put("delivered", "true"); + reopenedAlreadyDelivered = false; + } Display d = Display.getInstance(); if (d != null) { InviteStore.put(pending, "platform", d.getPlatformName()); @@ -1197,6 +1208,13 @@ private static void beginDeferred() { boolean reopen = (REASON_UNSUPPORTED.equals(why) && attributionWindow != 0) || (REASON_CONSENT_DENIED.equals(why) && !explicitlyDenied()); if (reopen) { + // The listener may already have been told about this install, + // and the marker is where that fact lives. Deleting it lost it, + // so the resumed lookup's attribution was written as + // undelivered and inviteReceived() arrived as a second callback + // on the next launch. It rides the pending record instead. + reopenedAlreadyDelivered = + InviteStore.getBoolean(marker, "delivered", false); InviteStore.delete(InviteStore.PENDING); state = STATE_NONE; s = STATE_NONE; @@ -1278,6 +1296,15 @@ private static void requestReferrer(InstallReferrerSource source) { // the direct attribution. Incrementing the epoch cannot invalidate a // callback that does not remember which epoch it belongs to. final int issued = lookupEpoch; + // A referrer read IS a lookup in flight, and only claim() and + // requestMatch() were saying so. A flush() during the read -- create() + // issues one unconditionally -- therefore treated it as stale, advanced + // the epoch and started again, and the guard above then discarded the + // exact answer when it arrived. Worse than an ordinary lost retry, + // because the source has already burned its once-only flag by then, so + // the deterministic result is gone for good and the replacement falls + // back to a statistical guess. + lookupIssuedAt = System.currentTimeMillis(); try { source.requestReferrer(new InstallReferrerCallback() { @Override @@ -1610,10 +1637,23 @@ static void handleResolution(String payload, String matchType, boolean deferred, // A re-attribution claim that found nothing. The earlier // attribution is still the answer for this install, so // nothing is terminal here -- terminalizing it 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 durable record, which still says RESOLVED, and told + // the listener "no invite" as a second, opposite callback + // after it had already been given one. + // + // Returning is not enough either: handleUrl wrote a PENDING + // record for the replacement before issuing this claim, so + // leaving it there kept the install pending, and every + // later flush and launch retried the failed replacement + // until the attempt cap finally reported unavailable -- + // still with the durable attribution sitting beside it. The + // replacement attempt is dropped and the install goes back + // to what it was. + InviteStore.delete(InviteStore.PENDING); + state = STATE_RESOLVED; + stateLoaded = true; + deferredStarted = false; + lookupIssuedAt = 0; return; } // Terminal, and it has to be durable. Deleting the record is @@ -1691,7 +1731,13 @@ private static void resolve(InviteAttribution a, String confidence) { // callback per install -- resetting the flag delivered inviteReceived() // a second time, immediately if the first had happened in an earlier // process and on the next launch if it had happened in this one. + // The attribution being replaced, or -- when there is none, because + // this lookup was resumed after a delivered refusal -- the pending + // record that carried the fact across the reopen. Map previous = InviteStore.read(InviteStore.ATTRIBUTION); + if (previous == null) { + previous = InviteStore.read(InviteStore.PENDING); + } record.put("delivered", String.valueOf(InviteStore.getBoolean(previous, "delivered", false))); // Storage was chosen over Preferences precisely because it reports a diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index a2bb713ac11..709f9642101 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -746,4 +746,95 @@ public void attributionUnavailable(String reason) { assertEquals(Invites.REASON_EXPIRED, told[0], "the late listener was told the wrong reason"); } + + @Test + @EdtTest + void aReferrerReadCountsAsALookupInFlight() { + // Only claim() and requestMatch() said so, so a flush() during the read + // -- create() issues one unconditionally -- treated it as stale, + // advanced the epoch, and the epoch guard then discarded the exact + // answer when it arrived. Worse than a lost retry: the source has + // already burned its once-only flag, so the deterministic result is + // gone and a statistical guess replaces it. + final InstallReferrerCallback[] held = new InstallReferrerCallback[1]; + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + held[0] = callback; + } + }); + Invites.checkForInvite(); + assertNotNull(held[0]); + int issued = Invites.currentLookupEpochForTest(); + + Invites.flush(); + assertEquals(issued, Invites.currentLookupEpochForTest(), + "flush() superseded a referrer read that was still outstanding"); + + held[0].onReferrer("utm_source=cn1_invite&cn1_invite=KEPT1", 0L, 0L); + Map pending = InviteStore.read(InviteStore.PENDING); + assertEquals("KEPT1", InviteStore.get(pending, "code", null), + "the exact referrer answer was discarded"); + } + + @Test + @EdtTest + void aFailedReplacementPutsTheInstallBackWhereItWas() { + // handleUrl writes a PENDING record for the replacement before issuing + // the claim, so simply returning left the install pending: 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. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST3", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND3"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_DIRECT, false); + + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + assertNull(InviteStore.read(InviteStore.PENDING), + "the failed replacement's pending record was left behind"); + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "the install came back pending on the next launch"); + } + + @Test + @EdtTest + void aResumedLookupDoesNotAnnounceItselfToAListenerAlreadyTold() { + // The refusal was delivered, so the listener has had its one callback + // for this install. Reopening deleted the marker that recorded that, + // and the resumed lookup's attribution was written as undelivered -- + // arriving as a second callback on the next launch. + final int[] told = new int[1]; + final int[] received = new int[1]; + InviteListener l = new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0]++; + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }; + Invites.setInviteListener(l); + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(1, told[0], "the refusal was not delivered, so this proves nothing"); + + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.handleResolution(InviteTestSupport.resolvedJson("LATER3", "c1", "sms"), + Invites.MATCH_FINGERPRINT, true); + + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + Invites.setInviteListener(l); + assertEquals(0, received[0], + "the resumed lookup announced itself to a listener already told"); + } } From abfb082689a8a6534b996d6f2581c6570dcc37c2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:24:04 +0300 Subject: [PATCH 21/70] Invites: provenance, abandonment, and a fragment that became part of 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) --- .../codename1/analytics/invite/Invites.java | 78 +++++++++- .../invite/InviteResilienceTest.java | 137 ++++++++++++++++++ .../invite/InviteUrlParsingTest.java | 26 ++++ 3 files changed, 238 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index dcba82b0f9f..b1d5994edd5 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -506,6 +506,15 @@ public static boolean handleUrl(String url) { // this a refused user who opened an invite link still had a profile // persisted -- by a different route to the one that was fixed. if (explicitlyDenied()) { + if (getAttribution() != null) { + // Already attributed, and the listener has had its callback. + // Writing a fresh DECLINED marker here contradicted the durable + // attribution -- which is still there and makes the state + // RESOLVED again on the next launch -- and delivered + // attributionUnavailable() as a second, opposite answer for an + // install that had already been given one. + return true; + } // Told, not silently dropped. checkForInvite() records the url as // consumed and skips the deferred path after this, so this is the // only chance the listener gets for this install -- and a @@ -526,6 +535,10 @@ public static boolean handleUrl(String url) { } Map pending = pendingRecord(); pending.put("code", code); + pending.put("codeSource", "universal_link"); + pending.put("codeMatch", MATCH_DIRECT); + pending.put("codeDeferred", "false"); + pending.put("codeReferrer", ""); // The referrer question is settled: this install came from a link we // are holding the code for, so a referrer read is no longer a better // answer waiting to happen. @@ -997,6 +1010,14 @@ static String extractCode(String url) { path = path.substring(0, rel); } } + // A fragment is not part of the path and is not part of the code, and + // an App Link commonly arrives with one still attached -- so + // /i/acme/ABC123#section claimed a code called "ABC123#section", which + // exists nowhere. + int hash = path.indexOf('#'); + if (hash >= 0) { + path = path.substring(0, hash); + } if (!path.startsWith("/i/")) { return null; } @@ -1105,6 +1126,27 @@ private static void setState(int s) { // answer was no". Durable, so no later launch repeats the lookup, and it // carries none of the device profile the pending record held -- the profile // exists to be matched, and there is nothing left to match it against. + // A pending record that sits BESIDE a resolved attribution is a + // re-attribution replacement, not this install's only answer. Every way of + // giving up on it -- a server no-match, the attempt cap, the window + // expiring -- has to drop the replacement and leave the install resolved, + // rather than writing a terminal marker the durable attribution contradicts + // and telling the listener "no invite" after it has already been told + // otherwise. + // + // Returns true when it handled the outcome. + private static boolean abandonReplacement() { + if (getAttribution() == null) { + return false; + } + InviteStore.delete(InviteStore.PENDING); + state = STATE_RESOLVED; + stateLoaded = true; + deferredStarted = false; + lookupIssuedAt = 0; + return true; + } + private static void markTerminal() { markTerminal(null); } @@ -1250,11 +1292,17 @@ private static void beginDeferred() { Map pending = pendingRecord(); long expires = InviteStore.getLong(pending, "expiresAt", 0); if (expires > 0 && System.currentTimeMillis() > expires) { + if (abandonReplacement()) { + return; + } markTerminal(REASON_EXPIRED); notifyUnavailable(REASON_EXPIRED); return; } if (InviteStore.getInt(pending, "attempts", 0) >= MAX_ATTEMPTS) { + if (abandonReplacement()) { + return; + } markTerminal(); notifyUnavailable(REASON_NO_MATCH); return; @@ -1268,7 +1316,17 @@ private static void beginDeferred() { deferredStarted = true; String code = InviteStore.get(pending, "code", null); if (code != null && code.length() > 0) { - claim(code, "universal_link", "", MATCH_DIRECT, false); + // Resent as what it was, not as a direct link. A referrer claim + // that timed out is persisted here and retried, and hard-coding + // the direct-link metadata reported it as invite_opened rather than + // invite_install and handed the app an attribution whose + // isDeferred() said false -- corrupting the install funnel for + // exactly the deterministic answers this retry exists to save. + String source = InviteStore.get(pending, "codeSource", "universal_link"); + String matchType = InviteStore.get(pending, "codeMatch", MATCH_DIRECT); + boolean deferred = InviteStore.getBoolean(pending, "codeDeferred", false); + claim(code, source, InviteStore.get(pending, "codeReferrer", ""), + matchType, deferred); return; } InstallReferrerSource source = referrerSource; @@ -1288,7 +1346,7 @@ private static boolean safeSupported(InstallReferrerSource source) { } } - private static void requestReferrer(InstallReferrerSource source) { + private static void requestReferrer(final InstallReferrerSource source) { // The epoch this read was ISSUED under, captured here. The platform // callback below can run long after a direct link arrived and advanced // the epoch, and reading the field at callback time made the old read @@ -1333,6 +1391,11 @@ public void run() { // ordinary retry path resends it. Map pending = pendingRecord(); pending.put("code", code); + pending.put("codeSource", "install_referrer"); + pending.put("codeMatch", MATCH_REFERRER); + pending.put("codeDeferred", "true"); + InviteStore.put(pending, "codeReferrer", + rawReferrer == null ? "" : rawReferrer); pending.remove("referrerRetry"); InviteStore.write(InviteStore.PENDING, pending); claim(code, "install_referrer", @@ -1360,7 +1423,16 @@ public void run() { // no-match answer to it must not be allowed to // settle the install as organic while a // deterministic answer is still reachable. - fallBackToMatch(!REASON_UNSUPPORTED.equals(reason)); + // Retryable only while the SOURCE would try + // again. Reading the reason alone was not enough: + // a successful read that returns an empty referrer + // reports REASON_NO_MATCH and burns the once-only + // flag, so it is definitive -- and treating it as + // transient left the lookup pending until the + // attempt budget ran out, for an answer that had + // already arrived. + fallBackToMatch(!REASON_UNSUPPORTED.equals(reason) + && safeSupported(source)); } }); } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 709f9642101..7968a82e040 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -25,6 +25,7 @@ import com.codename1.analytics.Analytics; import com.codename1.analytics.AnalyticsConsent; import com.codename1.analytics.ConsentMode; +import com.codename1.io.ConnectionRequest; import com.codename1.junit.EdtTest; import com.codename1.junit.FormTest; import java.io.ByteArrayInputStream; @@ -837,4 +838,140 @@ public void attributionUnavailable(String reason) { assertEquals(0, received[0], "the resumed lookup announced itself to a listener already told"); } + + @FormTest + void aRetriedReferrerClaimIsStillAReferrerClaim() { + // The persisted code was resent as a direct link, 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 answers this retry exists to save. + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=PROV1", 0L, 0L); + } + }); + Invites.checkForInvite(); + + // The retry itself, on the wire: what the record holds only matters if + // the resend uses it. + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + Invites.lookupRetryDelay = 0L; + Invites.flush(); + + String body = null; + for (ConnectionRequest r : implementation.getQueuedRequests()) { + if (r.getUrl() != null && r.getUrl().indexOf("/claim") >= 0) { + body = r.getRequestBody(); + } + } + assertNotNull(body, "the persisted referrer code was never resent"); + assertTrue(body.contains("PROV1"), body); + assertTrue(body.replace(" ", "").contains("\"source\":\"install_referrer\""), + "a referrer answer was resent as a direct link: " + body); + } + + @Test + @EdtTest + void anExhaustedReplacementLeavesTheEarlierAnswerStanding() { + // Every way of giving up on a replacement has to abandon it, not just + // the server no-match: the attempt cap wrote a terminal marker the + // durable attribution contradicts, and told the listener "no invite" + // after it had already been given one. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST4", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND4"); + + Map pending = InviteStore.read(InviteStore.PENDING); + pending.put("attempts", "99"); + InviteStore.write(InviteStore.PENDING, pending); + + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + Invites.forgetLoadedState(); + Invites.checkForInvite(); + + assertEquals(0, told[0], "an exhausted replacement told the listener the opposite"); + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + } + + @Test + @EdtTest + void aDeniedLinkDoesNotOverwriteAnAttributionAlreadyGiven() { + // Writing a fresh DECLINED marker contradicted the durable attribution, + // which is still there and makes the state RESOLVED again on the next + // launch, and delivered a second, opposite callback for one install. + final int[] delivered = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + delivered[0]++; + } + + public void attributionUnavailable(String reason) { + } + }); + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST5", "c1", "sms"), + Invites.MATCH_DIRECT, false); + assertEquals(1, delivered[0], "the attribution was not delivered, so this proves nothing"); + + // A later process: the callback has been given, and only the durable + // records remain. + Invites.forgetLoadedState(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND5"); + + assertEquals(0, told[0], "a denied link told an attributed install it had no invite"); + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "the state contradicted the durable attribution"); + } + + @Test + @EdtTest + void anEmptyButSuccessfulReferrerReadIsDefinitive() { + // The source burns its once-only flag for this case, so isSupported() + // can never read a referrer again -- but the reason it reports is the + // same one a transient failure uses, so the lookup stayed pending until + // the attempt budget ran out for an answer that had already arrived. + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + private boolean spent; + + public boolean isSupported() { + return !spent; + } + + public void requestReferrer(InstallReferrerCallback callback) { + spent = true; + callback.onUnavailable(Invites.REASON_NO_MATCH); + } + }); + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "a definitive empty referrer read was treated as retryable"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java index caf5f415be7..e1136d6a30f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -23,12 +23,17 @@ package com.codename1.analytics.invite; import com.codename1.io.Preferences; +import java.util.Map; +import com.codename1.junit.EdtTest; import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class InviteUrlParsingTest extends UITestBase { @@ -97,4 +102,25 @@ void valueIsSplitOnTheFirstEqualsOnly() { InviteTestSupport.freshInstall(); assertEquals("a=b", Invites.codeFromQuery("cn1_invite=a%3Db")); } + + @Test + @EdtTest + void aFragmentIsNotPartOfTheCode() { + // An App Link commonly arrives with the fragment still attached, and it + // is not part of the path -- so this claimed a code called + // "ABC123#section", which exists nowhere. + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/ABC123#section")); + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending); + assertEquals("ABC123", InviteStore.get(pending, "code", null)); + } + + @Test + @EdtTest + void aFragmentAfterAQueryIsAlsoStripped() { + assertTrue(Invites.handleUrl( + "https://cloud.codenameone.com/i/acme/ABC124?utm_source=x#top")); + Map pending = InviteStore.read(InviteStore.PENDING); + assertEquals("ABC124", InviteStore.get(pending, "code", null)); + } } From cdb508df4ff5fee4dc2f48d8e38e2b4da681173d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:41:38 +0300 Subject: [PATCH 22/70] Invites: a direct link is its own question, and a reopened one keeps 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) --- .../codename1/analytics/invite/Invites.java | 57 ++++++++++++++--- .../invite/InviteConsentAndErasureTest.java | 8 ++- .../invite/InviteResilienceTest.java | 62 ++++++++++++++++++- .../invite/InviteUrlParsingTest.java | 12 ++++ 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index b1d5994edd5..55b089618d4 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -220,6 +220,13 @@ public final class Invites { // than announcing itself a second time. Carried onto the pending record as // soon as one exists, which is what makes it survive the process. private static boolean reopenedAlreadyDelivered; + + // The original clock readings a reopened terminal marker carried, so the + // resumed lookup keeps the window it started with rather than restarting it + // from the moment consent was granted. + private static long reopenedFirstLaunch; + + private static long reopenedExpiresAt; private static boolean deferredStarted; // When the last claim or match was issued. flush() restarts only once this @@ -535,6 +542,15 @@ public static boolean handleUrl(String url) { } Map pending = pendingRecord(); pending.put("code", code); + // The window and the budget are reset, because this is a new question. + // Inheriting them from an older deferred lookup meant a link opened + // after that lookup had expired, or after its retries were spent, was + // marked expired by beginDeferred() before the saved code was ever + // looked at -- so an exact answer we were holding was never sent. + long now = System.currentTimeMillis(); + pending.put("firstLaunch", String.valueOf(now)); + pending.put("expiresAt", String.valueOf(now + attributionWindow)); + pending.put("attempts", "0"); pending.put("codeSource", "universal_link"); pending.put("codeMatch", MATCH_DIRECT); pending.put("codeDeferred", "false"); @@ -826,6 +842,8 @@ public static void reset() { lookupIssuedAt = 0; undelivered = null; reopenedAlreadyDelivered = false; + reopenedFirstLaunch = 0; + reopenedExpiresAt = 0; unacknowledged.clear(); } @@ -978,6 +996,15 @@ static String extractCode(String url) { if (url == null || url.length() == 0) { return null; } + // Stripped once, here, before either branch reads the url. Doing it on + // the path branch alone left the query branch -- which runs first -- + // parsing "?cn1_invite=ABC123#section" and claiming a code with the + // fragment glued to it. A fragment is client-side and part of neither + // the path nor the query. + int frag = url.indexOf('#'); + if (frag >= 0) { + url = url.substring(0, frag); + } int q = url.indexOf('?'); if (q >= 0) { String code = codeFromQuery(url.substring(q + 1)); @@ -1010,14 +1037,7 @@ static String extractCode(String url) { path = path.substring(0, rel); } } - // A fragment is not part of the path and is not part of the code, and - // an App Link commonly arrives with one still attached -- so - // /i/acme/ABC123#section claimed a code called "ABC123#section", which - // exists nowhere. - int hash = path.indexOf('#'); - if (hash >= 0) { - path = path.substring(0, hash); - } + if (!path.startsWith("/i/")) { return null; } @@ -1163,6 +1183,15 @@ private static void markTerminal(String reason) { private static void markTerminal(int terminalState, String reason) { Map done = new LinkedHashMap(); done.put("state", String.valueOf(terminalState)); + // The timing is carried, and only the timing. firstLaunch and expiresAt + // say nothing about the device -- they are two clock readings -- and + // without them a reopened marker started the window again from the + // moment consent was granted. A user who answers the prompt a week + // later would then have run a fresh fingerprint lookup and reported + // invite_install for somebody else's click. + Map before = InviteStore.read(InviteStore.PENDING); + InviteStore.put(done, "firstLaunch", InviteStore.get(before, "firstLaunch", null)); + InviteStore.put(done, "expiresAt", InviteStore.get(before, "expiresAt", null)); if (reason != null) { // Recorded on the marker, not only in memory. The listener contract // is "exactly one of the two methods per install, and the answer is @@ -1210,8 +1239,14 @@ private static Map pendingRecord() { } pending = new LinkedHashMap(); long now = System.currentTimeMillis(); - pending.put("firstLaunch", String.valueOf(now)); - pending.put("expiresAt", String.valueOf(now + attributionWindow)); + // Restored from the marker a reopen carried them on, when there is one, + // so granting consent late does not restart the attribution window. + pending.put("firstLaunch", String.valueOf(reopenedFirstLaunch > 0 + ? reopenedFirstLaunch : now)); + pending.put("expiresAt", String.valueOf(reopenedExpiresAt > 0 + ? reopenedExpiresAt : now + attributionWindow)); + reopenedFirstLaunch = 0; + reopenedExpiresAt = 0; pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); if (reopenedAlreadyDelivered) { @@ -1257,6 +1292,8 @@ private static void beginDeferred() { // on the next launch. It rides the pending record instead. reopenedAlreadyDelivered = InviteStore.getBoolean(marker, "delivered", false); + reopenedFirstLaunch = InviteStore.getLong(marker, "firstLaunch", 0); + reopenedExpiresAt = InviteStore.getLong(marker, "expiresAt", 0); InviteStore.delete(InviteStore.PENDING); state = STATE_NONE; s = STATE_NONE; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index 651dba3c650..dd887fc16bd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -324,8 +324,14 @@ private void assertNoProfileHeld(String message) { if (record == null) { return; } + // firstLaunch and expiresAt are NOT in this list. They are two clock + // readings, they describe no device, and they never leave it -- the + // marker is local. Keeping them is what stops a consent grant arriving + // a week later from restarting the attribution window and matching an + // unrelated click, so dropping them would cost privacy rather than + // protect it. for (String key : new String[] {"platform", "osVersion", "deviceModel", - "screenWidth", "screenHeight", "locale", "firstLaunch"}) { + "screenWidth", "screenHeight", "locale"}) { assertFalse(record.containsKey(key), message + " (held " + key + ")"); } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 7968a82e040..9cc068f245f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -134,8 +134,16 @@ void theTerminalMarkerKeepsNoDeviceProfile() { Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); Map marker = InviteStore.read(InviteStore.PENDING); assertNotNull(marker, "the answer has to be durable"); - assertEquals(1, marker.size(), "the marker kept fields beyond the state: " + marker); assertTrue(marker.containsKey("state")); + // The state, the reason, and the two clock readings that enforce the + // original window across a reopen -- and nothing that describes the + // device. Asserting a field count instead would fail the next time the + // marker legitimately carries one more, which is how this assertion + // came to be arguing against a privacy improvement. + for (String key : new String[] {"platform", "osVersion", "deviceModel", + "screenWidth", "screenHeight", "locale", "code"}) { + assertFalse(marker.containsKey(key), "the marker held " + key + ": " + marker); + } } @Test @@ -974,4 +982,56 @@ public void requestReferrer(InstallReferrerCallback callback) { assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), "a definitive empty referrer read was treated as retryable"); } + + @Test + @EdtTest + void aDirectLinkGetsItsOwnWindowAndBudget() { + // Inheriting them from an older deferred lookup meant a link opened + // after that lookup had expired, or after its retries were spent, was + // marked expired by beginDeferred() before the saved code was ever + // looked at -- so an exact answer we were holding was never sent. + Invites.checkForInvite(); + Map stale = InviteStore.read(InviteStore.PENDING); + assertNotNull(stale); + stale.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); + stale.put("attempts", String.valueOf(99)); + InviteStore.write(InviteStore.PENDING, stale); + + Invites.handleUrl("https://cloud.codenameone.com/i/acme/FRESH1"); + + Map pending = InviteStore.read(InviteStore.PENDING); + assertEquals("FRESH1", InviteStore.get(pending, "code", null)); + assertTrue(InviteStore.getLong(pending, "expiresAt", 0) > System.currentTimeMillis(), + "the direct claim inherited an expired window"); + // One, not zero: the reset puts it back to zero and the claim this + // call issues counts as the first attempt against the new budget. + assertEquals(1, InviteStore.getInt(pending, "attempts", -1), + "the direct claim inherited a spent retry budget"); + } + + @Test + @EdtTest + void reopeningAfterConsentKeepsTheOriginalWindow() { + // Without the original timings a reopened marker started the window + // again from the moment consent was granted, so a user answering the + // prompt a week later ran a fresh fingerprint lookup and could report + // invite_install for somebody else's click. + Invites.checkForInvite(); + Map first = InviteStore.read(InviteStore.PENDING); + assertNotNull(first); + // A distinctive value rather than whatever the clock produced a + // millisecond ago: a fresh window computed at grant time would land on + // almost the same number, and the test would pass by coincidence. + long originalExpiry = System.currentTimeMillis() + 123_456_789L; + first.put("expiresAt", String.valueOf(originalExpiry)); + InviteStore.write(InviteStore.PENDING, first); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + Analytics.setConsent(AnalyticsConsent.granted()); + + Map resumed = InviteStore.read(InviteStore.PENDING); + assertNotNull(resumed); + assertEquals(originalExpiry, InviteStore.getLong(resumed, "expiresAt", 0), + "granting consent restarted the attribution window"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java index e1136d6a30f..ba0f5657da0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -123,4 +123,16 @@ void aFragmentAfterAQueryIsAlsoStripped() { Map pending = InviteStore.read(InviteStore.PENDING); assertEquals("ABC124", InviteStore.get(pending, "code", null)); } + + @Test + @EdtTest + void aFragmentOnAQueryStyleLinkIsAlsoStripped() { + // The query branch runs first, so stripping on the path branch alone + // left it parsing "?cn1_invite=ABC125#section" and claiming a code with + // the fragment glued to it. + assertTrue(Invites.handleUrl( + "https://cloud.codenameone.com/i/acme?cn1_invite=ABC125#section")); + Map pending = InviteStore.read(InviteStore.PENDING); + assertEquals("ABC125", InviteStore.get(pending, "code", null)); + } } From 8bfb8ebecc2d733328bb01fa44ac59a0955b7604 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:13:22 +0300 Subject: [PATCH 23/70] Invites: four places the state machine forgot what it already knew 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) --- .../codename1/analytics/invite/Invites.java | 34 +++++- .../invite/InviteResilienceTest.java | 111 ++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 55b089618d4..c927e9e9d16 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -540,6 +540,12 @@ public static boolean handleUrl(String url) { Analytics.autoEvent("invite_opened", CATEGORY, p); return true; } + // The held answer is not the answer any more. A no-match or an expiry + // that became terminal with no listener is remembered in `undelivered`, + // and leaving it there handed a listener registered after this link + // resolved the stale unavailable result -- with deliveredThisRun then + // suppressing the correct one. + undelivered = null; Map pending = pendingRecord(); pending.put("code", code); // The window and the budget are reset, because this is a new question. @@ -773,6 +779,12 @@ public static long getAttributionWindow() { /// - `value`: true for last touch public static void setReattribution(boolean value) { reattribution = value; + // The cached state was derived under the old value. loadState() reads + // the pending record only when re-attribution is on, so a process that + // cached STATE_RESOLVED before this call would never look at a durable + // replacement again -- and setInviteListener(), which most applications + // call first, is enough to cache it. + stateLoaded = false; } /// Whether last touch attribution is enabled. @@ -1167,6 +1179,12 @@ private static boolean abandonReplacement() { return true; } + private static boolean hasSavedCode() { + Map pending = InviteStore.read(InviteStore.PENDING); + String code = InviteStore.get(pending, "code", null); + return code != null && code.length() > 0; + } + private static void markTerminal() { markTerminal(null); } @@ -1192,6 +1210,14 @@ private static void markTerminal(int terminalState, String reason) { Map before = InviteStore.read(InviteStore.PENDING); InviteStore.put(done, "firstLaunch", InviteStore.get(before, "firstLaunch", null)); InviteStore.put(done, "expiresAt", InviteStore.get(before, "expiresAt", null)); + // And the delivery state, for the same reason the resolved record + // inherits it: a reopened lookup that ends terminally has still been + // answered once, and dropping the flag here delivered a second + // attributionUnavailable() to a listener registered afterwards. The + // reopen protection covered a successful resolve and not this. + if (InviteStore.getBoolean(before, "delivered", false)) { + done.put("delivered", "true"); + } if (reason != null) { // Recorded on the marker, not only in memory. The listener contract // is "exactly one of the two methods per install, and the answer is @@ -1302,7 +1328,13 @@ private static void beginDeferred() { if (s == STATE_RESOLVED || s == STATE_NONE_FOUND || s == STATE_DECLINED) { return; } - if (attributionWindow == 0) { + if (attributionWindow == 0 && !hasSavedCode()) { + // The kill switch turns off DEFERRED attribution -- the statistical + // lookup that needs a window to mean anything. A code we are + // already holding is an exact answer that needs none, and refusing + // to send it reported "unsupported" for an invite the user really + // did open. + // // setState() only rewrites a record that already exists, and on a // fresh install none does -- so this answer was purely in memory // and the listener heard it again on every launch, breaking the diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 9cc068f245f..06cf22f223a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1034,4 +1034,115 @@ void reopeningAfterConsentKeepsTheOriginalWindow() { assertEquals(originalExpiry, InviteStore.getLong(resumed, "expiresAt", 0), "granting consent restarted the attribution window"); } + + @Test + @EdtTest + void theZeroWindowDoesNotDiscardAnExactCodeWeAreHolding() { + // setAttributionWindow(0) turns off the DEFERRED lookup, which is the + // one that needs a window to mean anything. A code already in hand is + // an exact answer that needs none, and refusing to send it reported + // "unsupported" for an invite the user really did open. + Invites.handleUrl("https://cloud.codenameone.com/i/acme/EXACT9"); + assertEquals("EXACT9", InviteStore.get( + InviteStore.read(InviteStore.PENDING), "code", null)); + + Invites.setAttributionWindow(0); + Invites.forgetLoadedState(); + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + Invites.checkForInvite(); + + assertNull(told[0], "the kill switch discarded an exact code we were holding"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + } + + @Test + @EdtTest + void turningOnReattributionLetsTheStateBeReadAgain() { + // loadState() reads the pending record only when re-attribution is on, + // so a process that cached STATE_RESOLVED before the setter ran would + // never look at a durable replacement again -- and setInviteListener, + // which most applications call first, is enough to cache it. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST6", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND6"); + Invites.setReattribution(false); + + // A later process: the listener is registered first, caching the state + // under the default, and only then is re-attribution turned on. + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + Invites.setReattribution(true); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "the cached state hid the durable replacement"); + } + + @Test + @EdtTest + void aResumedLookupThatEndsTerminallyIsNotAnnouncedTwice() { + // The reopen carries the delivery state onto the pending record, and + // the terminal rewrite dropped it -- so a listener registered in the + // next process was told a second time. + final int[] told = new int[1]; + InviteListener l = new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }; + Invites.setInviteListener(l); + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(1, told[0], "the refusal was not delivered, so this proves nothing"); + + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + Invites.setInviteListener(l); + assertEquals(1, told[0], "the resumed lookup announced its end a second time"); + } + + @Test + @EdtTest + void aDirectLinkDiscardsAHeldAnswerThatIsNoLongerTrue() { + // A no-match that became terminal with no listener is remembered, and + // leaving it there handed a listener registered after this link + // resolved the stale unavailable result -- with deliveredThisRun then + // suppressing the correct one. + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + Invites.handleUrl("https://cloud.codenameone.com/i/acme/LATER6"); + Invites.handleResolution(InviteTestSupport.resolvedJson("LATER6", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + final String[] unavailable = new String[1]; + final InviteAttribution[] received = new InviteAttribution[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0] = a; + } + + public void attributionUnavailable(String reason) { + unavailable[0] = reason; + } + }); + assertNull(unavailable[0], "a stale held answer was reported over a resolved one"); + assertNotNull(received[0], "the resolved attribution was suppressed by it"); + } } From 9dbd5957753a59272521e613ae4e0bbd2a538b34 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:30:38 +0300 Subject: [PATCH 24/70] Invites: the window bounds the guess, not the answer 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) --- .../com/codename1/analytics/Analytics.java | 26 ++++++++++ .../invite/InviteAttributionProvider.java | 12 +++++ .../codename1/analytics/invite/Invites.java | 8 ++- .../impl/android/AndroidImplementation.java | 7 +++ .../invite/InviteResilienceTest.java | 51 +++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index f54c290a913..ccb45770a32 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -148,8 +148,34 @@ public static void setConsentMode(ConsentMode mode) { if (mode == null) { return; } + List snapshot; synchronized (LOCK) { + if (mode == consentMode) { + return; + } consentMode = mode; + snapshot = new ArrayList(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); + } } } diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index 2a3aa624850..d553ebfeeec 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -106,6 +106,18 @@ public void onConsentChanged(AnalyticsConsent consent) { // analytics while a declined invite lookup stayed stopped and a // resolved attribution's dimensions stayed cleared, so the two // disagreed about the same user. + // No recorded choice, so the MODE decides -- and the two answers are + // not "allowed" and "refused". Under OPT_IN the prompt is simply + // unanswered and NOTHING happens: reporting a refusal there would + // delete the profile captured on the first launch and move to DECLINED + // for a user who has refused nothing, which is the paragraph above. + // Under OPT_OUT the implicit allow is in force and that is a real + // transition. + // + // Analytics.setConsentMode now dispatches here when the mode changes, + // which is what lets a switch to OPT_OUT reach this at all -- ordinary + // analytics used to resume on that switch while a declined lookup + // stayed stopped and an attribution's dimensions stayed cleared. if (Analytics.getConsentMode() == ConsentMode.OPT_OUT) { Invites.onConsentChanged(true); } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index c927e9e9d16..e71dc9e4863 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1359,7 +1359,13 @@ private static void beginDeferred() { return; } Map pending = pendingRecord(); - long expires = InviteStore.getLong(pending, "expiresAt", 0); + // The window bounds the DEFERRED lookup, and a code we are holding is + // not one -- it is an exact answer. Applying the expiry to it lost that + // answer for the two cases where a saved code coexists with an expired + // window: a zero window, where handleUrl records an expiry of "now", + // and a first claim that failed and is being retried after the window + // ran out. Same reasoning as the kill switch above. + long expires = hasSavedCode() ? 0 : InviteStore.getLong(pending, "expiresAt", 0); if (expires > 0 && System.currentTimeMillis() > expires) { if (abandonReplacement()) { return; diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 4c4ae25dbcc..3552619336f 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1697,6 +1697,13 @@ static void dispatchNewIntentUrl(Intent intent) { // rather than whatever the previous intent left cached. instance.setAppArg(null); clearIntentProperties(); + // And the intent's data is consumed, exactly as the lazy + // getAppArg() path consumes it. CodenameOneActivity.onStop() clears + // the app arg, so leaving the data on the intent meant the next + // read after a resume rebuilt the same url from it, and an + // application that handles AppArg in start() saw the deep link a + // second time -- opening the same invite twice for one tap. + intent.setData(null); Display.getInstance().setProperty("AppArg", data.toString()); } catch (Throwable t) { com.codename1.io.Log.e(t); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 06cf22f223a..f1f54d9a862 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1145,4 +1145,55 @@ public void attributionUnavailable(String reason) { assertNull(unavailable[0], "a stale held answer was reported over a resolved one"); assertNotNull(received[0], "the resolved attribution was suppressed by it"); } + + @Test + @EdtTest + void aSavedExactCodeIsNotSubjectToTheDeferredWindow() { + // The window bounds the deferred lookup, and a code we are holding is + // an exact answer rather than one. Applying the expiry to it lost that + // answer 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. + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SAVED9"); + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending); + pending.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); + InviteStore.write(InviteStore.PENDING, pending); + + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + Invites.forgetLoadedState(); + Invites.checkForInvite(); + + assertNull(told[0], "an exact code we were holding was marked expired"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + } + + @Test + @EdtTest + void switchingToOptOutResumesADeclinedLookup() { + // setConsentMode changes what an absent choice means, so it changes + // what is allowed -- and it dispatched to no provider, so ordinary + // analytics resumed while a declined lookup stayed stopped and an + // attribution's dimensions stayed cleared. + Analytics.setConsentMode(ConsentMode.OPT_IN); + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + Analytics.setConsent(null); + assertEquals(Invites.STATE_DECLINED, Invites.getState(), + "clearing the choice under opt-in must change nothing"); + + Analytics.setConsentMode(ConsentMode.OPT_OUT); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "switching to opt-out did not resume the declined lookup"); + } } From 8e45b87689f3b86ef2fbd6dcc762b0697dad9ec6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:03:44 +0300 Subject: [PATCH 25/70] Invites: one delivery is not one url, and a failed write is not a resolution 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) --- .../analytics/invite/InviteStore.java | 13 ++++++ .../codename1/analytics/invite/Invites.java | 40 ++++++++++++++--- .../invite/InviteResilienceTest.java | 43 +++++++++++++++++++ .../analytics/invite/InviteTestSupport.java | 12 ++++++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index c55400cecfa..158a8ada13e 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -101,7 +101,20 @@ static Map read(String record) { // Returns false when the record did not reach the disk. Callers that care // about exactly-once behaviour check this; the rest may ignore it. + // The same seam for a named record. 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. + private static String failNextNamed; + + static void failNextWriteForTest(String name) { + failNextNamed = name; + } + static boolean write(String record, Map values) { + if (record != null && record.equals(failNextNamed)) { + failNextNamed = null; + return false; + } try { Storage s = Storage.getInstance(); if (s == null) { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index e71dc9e4863..e3d85cfa958 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -188,6 +188,8 @@ public final class Invites { static final String PROPERTY_SLUG = "invite.slug"; static final String PREF_SLUG = "cn1$inviteSlug"; + // Kept only so reset() can clear what earlier versions of this class + // persisted. Nothing writes it any more -- see checkForInvite. static final String PREF_CONSUMED_ARG = "cn1$inviteConsumedArg"; // The referrer key the link service puts on the store url. Compared with @@ -215,6 +217,9 @@ public final class Invites { // a later launch reaches this answer again through the ordinary path. private static String undelivered; + // The launch argument already handled in THIS run. See checkForInvite. + private static String consumedArg; + // Set when a terminal marker that had already been delivered is reopened, // so the attribution the resumed lookup writes inherits that fact rather // than announcing itself a second time. Carried onto the pending record as @@ -477,12 +482,25 @@ public static boolean checkForInvite() { if (d != null) { appArg = d.getProperty("AppArg", null); } + // Deduplicated for this RUN, not for the life of the install. + // + // The point is to ignore repeated reads of one delivery -- an app that + // calls this from start() and again from a form -- and a durable record + // could not tell those apart from a second tap on the same link, which + // delivers the identical string. So the same link tapped again was + // ignored for ever: the install lost its invite_opened re-engagement + // event, and under re-attribution the later open could never win. + // + // What made the durable guard necessary was Android handing the same + // launch intent back on a later start. Both paths that read it now + // consume the intent's data -- the lazy getAppArg() always did, and + // dispatchNewIntentUrl does as well -- so a stale intent no longer + // reproduces the argument. boolean consumed = false; - if (appArg != null && appArg.length() > 0 - && !appArg.equals(Preferences.get(PREF_CONSUMED_ARG, ""))) { + if (appArg != null && appArg.length() > 0 && !appArg.equals(consumedArg)) { consumed = handleUrl(appArg); if (consumed) { - Preferences.set(PREF_CONSUMED_ARG, appArg); + consumedArg = appArg; } } if (!consumed) { @@ -607,6 +625,7 @@ private static void loadAttribution() { // the answer survives a relaunch, and nothing else can check that. static void forgetLoadedState() { undelivered = null; + consumedArg = null; lookupIssuedAt = 0; stateLoaded = false; attributionLoaded = false; @@ -853,6 +872,7 @@ public static void reset() { deferredStarted = false; lookupIssuedAt = 0; undelivered = null; + consumedArg = null; reopenedAlreadyDelivered = false; reopenedFirstLaunch = 0; reopenedExpiresAt = 0; @@ -1891,9 +1911,19 @@ private static void resolve(InviteAttribution a, String confidence) { // failed write, so the result is checked. Deleting the pending record // after a failed write would leave neither an attribution nor any retry // information, losing the resolution permanently at the next restart. - if (InviteStore.write(InviteStore.ATTRIBUTION, record)) { - InviteStore.delete(InviteStore.PENDING); + if (!InviteStore.write(InviteStore.ATTRIBUTION, record)) { + // The store is full or read-only. Everything below assumes the + // record is on disk: deliverPending() re-reads it before calling + // the listener 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. The + // pending record is deliberately left in place, so the next flush + // or launch resends the lookup. + Log.p("invite: the attribution could not be persisted, so the lookup stays " + + "pending and will be retried", Log.WARNING); + return; } + InviteStore.delete(InviteStore.PENDING); resolved = a; attributionLoaded = true; state = STATE_RESOLVED; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index f1f54d9a862..d9f60a2fa29 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -26,6 +26,7 @@ import com.codename1.analytics.AnalyticsConsent; import com.codename1.analytics.ConsentMode; import com.codename1.io.ConnectionRequest; +import com.codename1.ui.Display; import com.codename1.junit.EdtTest; import com.codename1.junit.FormTest; import java.io.ByteArrayInputStream; @@ -1196,4 +1197,46 @@ void switchingToOptOutResumesADeclinedLookup() { assertEquals(Invites.STATE_PENDING, Invites.getState(), "switching to opt-out did not resume the declined lookup"); } + + @Test + @EdtTest + void tappingTheSameLinkAgainInALaterRunIsProcessed() { + // Through checkForInvite, which is where the deduplication lives -- a + // test calling handleUrl directly never reaches it and proves nothing. + // + // The durable guard could not tell a repeated read of one delivery from + // a second tap, which delivers the identical string, so the same link + // was ignored for ever: the install lost its invite_opened + // re-engagement event, and under re-attribution the later open could + // never win. + String url = "https://cloud.codenameone.com/i/acme/TAP1"; + Display.getInstance().setProperty("AppArg", url); + assertTrue(Invites.checkForInvite(), "the first delivery was not handled"); + + // Repeated reads within one run are still ignored, which is what the + // deduplication is for. + assertFalse(Invites.checkForInvite(), "one delivery was handled twice"); + + // A later run: the same url arrives again from a second tap. + Invites.forgetLoadedState(); + Display.getInstance().setProperty("AppArg", url); + assertTrue(Invites.checkForInvite(), "a second tap on the same link was ignored"); + } + + @FormTest + void aFailedAttributionWriteLeavesTheLookupPending() { + // Everything after the 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 a restart. + Invites.checkForInvite(); + InviteStore.failNextWriteForTest(InviteStore.ATTRIBUTION); + Invites.handleResolution(InviteTestSupport.resolvedJson("NOSPACE1", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a failed write still reported the install as resolved"); + assertNotNull(InviteStore.read(InviteStore.PENDING), + "the retry information was thrown away with it"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index 3e9b7b6b649..ca6510d232b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -26,6 +26,7 @@ import com.codename1.analytics.AnalyticsConsent; import com.codename1.analytics.ConsentMode; import com.codename1.io.Preferences; +import com.codename1.ui.Display; /** * Puts the static invite state back to a fresh-install baseline. Invites keeps @@ -37,6 +38,7 @@ private InviteTestSupport() { } static RecordingProvider freshInstall() { + clearAppArg(); Analytics.clearProviders(); Analytics.clearDimensions(); Analytics.setConsentMode(ConsentMode.OPT_IN); @@ -58,6 +60,7 @@ static RecordingProvider freshInstall() { } static void tearDown() { + clearAppArg(); Invites.setInviteListener(null); Invites.registerInstallReferrerSource(null); Invites.reset(); @@ -69,6 +72,15 @@ static void tearDown() { Preferences.delete(Invites.PREF_CONSUMED_ARG); } + // The launch argument is process-wide, so a test that sets one and does + // not clear it sends every later test down the direct-link path. + private static void clearAppArg() { + Display d = Display.getInstance(); + if (d != null) { + d.setProperty("AppArg", null); + } + } + /** A canned server answer, in the shape the link service returns. */ static String resolvedJson(String code, String campaign, String channel) { return "{\"resolved\":true,\"code\":\"" + code + "\",\"campaign\":\"" From 9324a403e9d890ec1a11b996d38ff05c4be8c02c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:20:29 +0300 Subject: [PATCH 26/70] Invites: consuming the argument, and three restarts that should not have 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) --- .../codename1/analytics/invite/Invites.java | 68 +++++++++++-- .../invite/InviteResilienceTest.java | 98 ++++++++++++++++++- 2 files changed, 155 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index e3d85cfa958..08ccc336145 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -199,7 +199,9 @@ public final class Invites { // the devices nobody can reproduce on. private static final String REFERRER_KEY = "cn1_invite"; - private static final int MAX_ATTEMPTS = 5; + // Package private so a test can drive the attempt cap without five round + // trips. + static final int MAX_ATTEMPTS = 5; private static String linkBase; private static long attributionWindow = DEFAULT_ATTRIBUTION_WINDOW; @@ -217,9 +219,6 @@ public final class Invites { // a later launch reaches this answer again through the ordinary path. private static String undelivered; - // The launch argument already handled in THIS run. See checkForInvite. - private static String consumedArg; - // Set when a terminal marker that had already been delivered is reopened, // so the attribution the resumed lookup writes inherits that fact rather // than announcing itself a second time. Carried onto the pending record as @@ -496,11 +495,25 @@ public static boolean checkForInvite() { // consume the intent's data -- the lazy getAppArg() always did, and // dispatchNewIntentUrl does as well -- so a stale intent no longer // reproduces the argument. + // The argument is CONSUMED, not remembered. + // + // Remembering the last value cannot tell two deliveries of one url + // apart from two reads of one delivery -- and a live process really can + // span both, an Android onNewIntent after the app is backgrounded being + // the ordinary case. So the property is cleared instead: a later read + // sees nothing, and a genuine second delivery sets it again and is + // handled. + // + // Only when this really is an invite. Anything else is left exactly as + // it arrived, so an application routing its own deep links is + // unaffected -- and the property is read here after + // Display.setProperty has already fired the external-url dispatch, so + // a router that consumes it has done so before this runs. boolean consumed = false; - if (appArg != null && appArg.length() > 0 && !appArg.equals(consumedArg)) { + if (appArg != null && appArg.length() > 0) { consumed = handleUrl(appArg); - if (consumed) { - consumedArg = appArg; + if (consumed && d != null) { + d.setProperty("AppArg", null); } } if (!consumed) { @@ -625,7 +638,6 @@ private static void loadAttribution() { // the answer survives a relaunch, and nothing else can check that. static void forgetLoadedState() { undelivered = null; - consumedArg = null; lookupIssuedAt = 0; stateLoaded = false; attributionLoaded = false; @@ -872,7 +884,6 @@ public static void reset() { deferredStarted = false; lookupIssuedAt = 0; undelivered = null; - consumedArg = null; reopenedAlreadyDelivered = false; reopenedFirstLaunch = 0; reopenedExpiresAt = 0; @@ -910,7 +921,18 @@ static void onConsentChanged(boolean allowed) { // window may well have closed. beginDeferred() reopens the marker // itself, so calling it is the whole fix. int s = getState(); - if (s == STATE_PENDING || s == STATE_DECLINED) { + // STATE_DECLINED has nothing outstanding by definition -- the + // withdrawal that produced it discarded whatever was -- so only + // STATE_PENDING is gated on the in-flight check. + if (s == STATE_DECLINED || (s == STATE_PENDING && !lookupInFlight())) { + // Only when nothing is outstanding, for the reason flush() + // checks the same thing. An application may call setConsent() + // again with analytics still allowed -- to change only + // personalization or ad storage -- and restarting on that + // queued a second lookup whose answer was every bit as valid as + // the first, so the funnel event fired twice; repeated updates + // also spent the retry budget without a failure. + // // The refusal may have been recorded for a listener that had // not registered yet. It is not the answer any more, and // leaving it held meant a lookup that went on to resolve was @@ -934,6 +956,21 @@ static void onConsentChanged(boolean allowed) { // end of the window. The epoch bump additionally discards any response // already in flight. lookupEpoch++; + // Nothing is outstanding once the epoch has moved: any response still + // on the wire fails the guard. Saying so here is what lets a later + // grant resume immediately rather than waiting out a retry delay for a + // request that can no longer be acted on. + lookupIssuedAt = 0; + if (abandonReplacement()) { + // A replacement running beside an existing attribution. Withdrawing + // consent stops the replacement; it does not un-attribute the + // install, whose record is still there and makes the state resolved + // again on the next launch. Writing a DECLINED marker here told a + // registered listener "no invite" as a second, contradictory + // callback for an install it had already been told about. + clearDimensions(); + return; + } if (getState() == STATE_PENDING) { // The profile goes and the answer stays. Deleting the record left // STATE_DECLINED in memory only -- setState() has nothing to @@ -1919,6 +1956,17 @@ private static void resolve(InviteAttribution a, String confidence) { // delivered nor asked for again until the process restarted. The // pending record is deliberately left in place, so the next flush // or launch resends the lookup. + // And the attempt is given back. Leaving the counter at the cap + // meant the next flush took the attempt-cap branch and marked the + // install terminal instead of performing the retry this promises -- + // so the very last response, the one most likely to be the only one + // left, could never be stored. + Map retry = InviteStore.read(InviteStore.PENDING); + if (retry != null) { + int spent = InviteStore.getInt(retry, "attempts", 0); + retry.put("attempts", String.valueOf(spent > 0 ? spent - 1 : 0)); + InviteStore.write(InviteStore.PENDING, retry); + } Log.p("invite: the attribution could not be persisted, so the lookup stays " + "pending and will be retried", Log.WARNING); return; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index d9f60a2fa29..88bfd39ac09 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1217,12 +1217,38 @@ void tappingTheSameLinkAgainInALaterRunIsProcessed() { // deduplication is for. assertFalse(Invites.checkForInvite(), "one delivery was handled twice"); - // A later run: the same url arrives again from a second tap. + // And a second delivery IN THE SAME RUN -- an Android onNewIntent + // after the app is backgrounded, which is the ordinary case -- is a new + // delivery, not a repeated read. + Display.getInstance().setProperty("AppArg", url); + assertTrue(Invites.checkForInvite(), + "a second delivery in the same run was ignored"); + + // A later run behaves the same way. Invites.forgetLoadedState(); Display.getInstance().setProperty("AppArg", url); assertTrue(Invites.checkForInvite(), "a second tap on the same link was ignored"); } + @Test + @EdtTest + void anInviteArgumentIsConsumedAndAnythingElseIsLeftAlone() { + // Consuming it is what distinguishes a delivery from a read. Only an + // invite is consumed: an application routing its own deep links must + // find its argument exactly as it arrived. + Display.getInstance().setProperty("AppArg", + "https://cloud.codenameone.com/i/acme/EATEN1"); + assertTrue(Invites.checkForInvite()); + assertNull(Display.getInstance().getProperty("AppArg", null), + "the invite argument was left behind for the next read"); + + Display.getInstance().setProperty("AppArg", "https://example.com/some/other/link"); + assertFalse(Invites.checkForInvite()); + assertEquals("https://example.com/some/other/link", + Display.getInstance().getProperty("AppArg", null), + "an argument that is not an invite was consumed"); + } + @FormTest void aFailedAttributionWriteLeavesTheLookupPending() { // Everything after the write assumes the record is on disk: @@ -1239,4 +1265,74 @@ void aFailedAttributionWriteLeavesTheLookupPending() { assertNotNull(InviteStore.read(InviteStore.PENDING), "the retry information was thrown away with it"); } + + @Test + @EdtTest + void aConsentUpdateThatChangesNothingDoesNotQueueASecondLookup() { + // An application may call setConsent again with analytics still allowed + // -- to change only personalization or ad storage -- and restarting on + // that queued a second lookup whose answer was as valid as the first, + // so the funnel event fired twice and the retry budget was spent + // without a failure. + Invites.checkForInvite(); + Map pending = InviteStore.read(InviteStore.PENDING); + int attempts = InviteStore.getInt(pending, "attempts", 0); + + for (int i = 0; i < 5; i++) { + Analytics.setConsent(AnalyticsConsent.builder().analytics(true) + .personalization(i % 2 == 0).build()); + } + + Map now = InviteStore.read(InviteStore.PENDING); + assertEquals(attempts, InviteStore.getInt(now, "attempts", 0), + "consent updates queued lookups for a request that had not failed"); + } + + @Test + @EdtTest + void withdrawingConsentDuringAReplacementAbandonsIt() { + // Withdrawing consent stops the replacement; it does not un-attribute + // the install, whose record is still there and makes the state resolved + // again on the next launch. Writing a DECLINED marker told a registered + // listener "no invite" as a second, contradictory callback. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST7", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND7"); + + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + assertEquals(0, told[0], "a withdrawal told an attributed install it had no invite"); + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + } + + @FormTest + void aFailedWriteOnTheLastAttemptCanStillBeRetried() { + // Leaving the counter at the cap meant the next flush took the + // attempt-cap branch and marked the install terminal instead of + // performing the retry -- so the very last response, the one most + // likely to be the only one left, could never be stored. + Invites.checkForInvite(); + Map pending = InviteStore.read(InviteStore.PENDING); + pending.put("attempts", String.valueOf(Invites.MAX_ATTEMPTS)); + InviteStore.write(InviteStore.PENDING, pending); + + InviteStore.failNextWriteForTest(InviteStore.ATTRIBUTION); + Invites.handleResolution(InviteTestSupport.resolvedJson("LAST1", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + Map after = InviteStore.read(InviteStore.PENDING); + assertNotNull(after); + assertTrue(InviteStore.getInt(after, "attempts", 0) < Invites.MAX_ATTEMPTS, + "the promised retry could never happen: the budget was still exhausted"); + } } From e7c14df15e1a0235cc926c902c32efdfd5a76d29 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:02:02 +0300 Subject: [PATCH 27/70] Invites: an erasure has to reach the durable records, not just the dimensions 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) --- .../codename1/analytics/invite/Invites.java | 19 ++++++++++++++++ .../invite/InviteConsentAndErasureTest.java | 22 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 08ccc336145..770384456f0 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -615,6 +615,7 @@ public static boolean handleUrl(String url) { /// /// the attribution public static InviteAttribution getAttribution() { + ensureProvider(); loadAttribution(); return resolved; } @@ -653,6 +654,7 @@ static void forgetLoadedState() { /// /// the current state public static int getState() { + ensureProvider(); loadState(); return state; } @@ -833,6 +835,7 @@ public static boolean isReattribution() { /// deferred match. Called for you on the paths that matter; exposed for /// an application that knows it has just regained connectivity. public static void flush() { + ensureProvider(); drainOutbox(); // A deferred lookup that failed because the first launch was offline // leaves deferredStarted set, and nothing else clears it inside the @@ -988,6 +991,22 @@ static void onConsentChanged(boolean allowed) { // Registers the provider that gives us the erasure and consent hooks. // Analytics.clearProviders() can drop it, so this re-registers on facade // entry rather than only once; the provider list is a handful of entries. + // Called by EVERY entry point that reads or transmits stored invite data, + // not only the ones that start something. + // + // Analytics.clearProviders() is public and the deprecated + // AnalyticsService.init() calls it, so this provider can be absent when an + // erasure runs. Analytics.resetClientId() clears the reserved dimensions + // itself, which needs no provider -- but the durable records are ours, and + // only this provider's init() hook drops them. Registering here re-runs + // that hook (addProvider calls init immediately), so the identity change is + // noticed before anything reads the old attribution or sends the old + // registration outbox under the new id. + // + // Analytics deliberately does not do this for us: a reference from + // com.codename1.analytics to this 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. private static void ensureProvider() { try { List providers = Analytics.getProviders(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index dd887fc16bd..d42af34ae9c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -360,4 +360,26 @@ void anErasureClearsTheReferralDimensionsEvenWithNoProviderRegistered() { assertEquals("pro", Analytics.getDimensions().get("plan"), "the application's own dimension must survive an erasure"); } + + @FormTest + void anErasureDropsTheDurableRecordsEvenWithNoProviderRegistered() { + // resetClientId clears the reserved dimensions itself, which needs no + // provider -- but the durable records are ours and only the provider's + // init hook drops them. Analytics.clearProviders() is public and the + // deprecated AnalyticsService.init() calls it, so an erasure really can + // run with the provider absent; every entry point that reads or + // transmits stored data re-registers first, which re-runs that hook. + InviteTestSupport.freshInstall(); + Invites.handleResolution(InviteTestSupport.resolvedJson("ERASE1", "spring", "sms"), + Invites.MATCH_DIRECT, false); + assertNotNull(Invites.getAttribution()); + + Analytics.clearProviders(); + Analytics.resetClientId(); + + assertNull(Invites.getAttribution(), + "the old referral identity survived an erasure and can be read under the new id"); + assertNull(InviteStore.read(InviteStore.ATTRIBUTION), + "the durable attribution record was left on the device"); + } } From b3667c20d6ebafd1398af847a79dfd1d1515bacf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:18:26 +0300 Subject: [PATCH 28/70] Invites: the reopened marker is converted, not rebuilt 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) --- .../codename1/analytics/invite/Invites.java | 141 ++++++++++-------- .../invite/InviteResilienceTest.java | 44 ++++++ 2 files changed, 126 insertions(+), 59 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 770384456f0..6182243d67f 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -219,18 +219,6 @@ public final class Invites { // a later launch reaches this answer again through the ordinary path. private static String undelivered; - // Set when a terminal marker that had already been delivered is reopened, - // so the attribution the resumed lookup writes inherits that fact rather - // than announcing itself a second time. Carried onto the pending record as - // soon as one exists, which is what makes it survive the process. - private static boolean reopenedAlreadyDelivered; - - // The original clock readings a reopened terminal marker carried, so the - // resumed lookup keeps the window it started with rather than restarting it - // from the moment consent was granted. - private static long reopenedFirstLaunch; - - private static long reopenedExpiresAt; private static boolean deferredStarted; // When the last claim or match was issued. flush() restarts only once this @@ -553,12 +541,27 @@ public static boolean handleUrl(String url) { // install that had already been given one. return true; } + // The code is recorded on the way to the marker, which carries it + // across the refusal. This branch runs BEFORE the pending record is + // written, so without this there is nothing for markTerminal to + // carry, and a user who grants consent afterwards has the exact + // claim replaced by a referrer read or a statistical match. + Map denied = InviteStore.read(InviteStore.PENDING); + if (denied == null) { + denied = new LinkedHashMap(); + } + denied.put("code", code); + denied.put("codeSource", "universal_link"); + denied.put("codeMatch", MATCH_DIRECT); + denied.put("codeDeferred", "false"); + InviteStore.write(InviteStore.PENDING, denied); // Told, not silently dropped. checkForInvite() records the url as // consumed and skips the deferred path after this, so this is the // only chance the listener gets for this install -- and a // registered one heard nothing at all. - markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED); - notifyUnavailable(REASON_CONSENT_DENIED); + if (markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED)) { + notifyUnavailable(REASON_CONSENT_DENIED); + } return true; } if (getState() == STATE_RESOLVED && !reattribution) { @@ -887,9 +890,6 @@ public static void reset() { deferredStarted = false; lookupIssuedAt = 0; undelivered = null; - reopenedAlreadyDelivered = false; - reopenedFirstLaunch = 0; - reopenedExpiresAt = 0; unacknowledged.clear(); } @@ -980,8 +980,9 @@ static void onConsentChanged(boolean allowed) { // rewrite once the record is gone -- so the next launch read // STATE_NONE and told the listener again. The marker carries the // reason, which is what lets a later grant reopen it. - markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED); - notifyUnavailable(REASON_CONSENT_DENIED); + if (markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED)) { + notifyUnavailable(REASON_CONSENT_DENIED); + } } clearDimensions(); } @@ -1261,8 +1262,8 @@ private static boolean hasSavedCode() { return code != null && code.length() > 0; } - private static void markTerminal() { - markTerminal(null); + private static boolean markTerminal() { + return markTerminal(null); } // reason is recorded only when the answer could stop being true. A window @@ -1270,11 +1271,13 @@ private static void markTerminal() { // ships a non-zero window is asking for attribution again -- so that one // marker is reopened rather than being permanent, which is why it is the // only one that carries a reason. - private static void markTerminal(String reason) { - markTerminal(STATE_NONE_FOUND, reason); + private static boolean markTerminal(String reason) { + return markTerminal(STATE_NONE_FOUND, reason); } - private static void markTerminal(int terminalState, String reason) { + // Returns false when the marker could not be persisted, in which case + // NOTHING is committed and the caller must not report the outcome. + private static boolean markTerminal(int terminalState, String reason) { Map done = new LinkedHashMap(); done.put("state", String.valueOf(terminalState)); // The timing is carried, and only the timing. firstLaunch and expiresAt @@ -1304,9 +1307,31 @@ private static void markTerminal(int terminalState, String reason) { // got neither callback for the life of the install. done.put("reason", reason); } - InviteStore.write(InviteStore.PENDING, done); + // And the direct-link details, when there are any. + // + // A refusal is reopenable, so the code has to survive it: discarding it + // meant 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. Four + // short fields, and none of them describes the device. + for (String key : new String[] {"code", "codeSource", "codeMatch", "codeDeferred", + "codeReferrer"}) { + InviteStore.put(done, key, InviteStore.get(before, key, null)); + } + if (!InviteStore.write(InviteStore.PENDING, done)) { + // Nothing is committed. Reporting a terminal outcome the device + // cannot remember meant the same lookup and the same callback + // repeated after every restart -- or, worse, the delivery flag + // landed on the OLD pending record and left the state at PENDING, + // so a supposedly settled lookup ran again and could never deliver + // its answer. + Log.p("invite: a terminal answer could not be persisted; it will be reached " + + "again rather than reported now", Log.WARNING); + return false; + } state = terminalState; stateLoaded = true; + return true; } // The terminal answer this install reached, if it was never delivered. @@ -1341,20 +1366,10 @@ private static Map pendingRecord() { } pending = new LinkedHashMap(); long now = System.currentTimeMillis(); - // Restored from the marker a reopen carried them on, when there is one, - // so granting consent late does not restart the attribution window. - pending.put("firstLaunch", String.valueOf(reopenedFirstLaunch > 0 - ? reopenedFirstLaunch : now)); - pending.put("expiresAt", String.valueOf(reopenedExpiresAt > 0 - ? reopenedExpiresAt : now + attributionWindow)); - reopenedFirstLaunch = 0; - reopenedExpiresAt = 0; + pending.put("firstLaunch", String.valueOf(now)); + pending.put("expiresAt", String.valueOf(now + attributionWindow)); pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); - if (reopenedAlreadyDelivered) { - pending.put("delivered", "true"); - reopenedAlreadyDelivered = false; - } Display d = Display.getInstance(); if (d != null) { InviteStore.put(pending, "platform", d.getPlatformName()); @@ -1387,18 +1402,21 @@ private static void beginDeferred() { boolean reopen = (REASON_UNSUPPORTED.equals(why) && attributionWindow != 0) || (REASON_CONSENT_DENIED.equals(why) && !explicitlyDenied()); if (reopen) { - // The listener may already have been told about this install, - // and the marker is where that fact lives. Deleting it lost it, - // so the resumed lookup's attribution was written as - // undelivered and inviteReceived() arrived as a second callback - // on the next launch. It rides the pending record instead. - reopenedAlreadyDelivered = - InviteStore.getBoolean(marker, "delivered", false); - reopenedFirstLaunch = InviteStore.getLong(marker, "firstLaunch", 0); - reopenedExpiresAt = InviteStore.getLong(marker, "expiresAt", 0); - InviteStore.delete(InviteStore.PENDING); - state = STATE_NONE; - s = STATE_NONE; + // The marker is CONVERTED, not deleted and rebuilt. + // + // Everything it carries has to survive the reopening: the + // original window, so a late grant does not start a fresh one; + // the delivered flag, so the listener is not told twice; and + // the direct-link code, so an exact answer is not replaced by a + // guess. Rebuilding from scratch lost each of those in turn, + // one review round at a time, which is what this shape exists + // to stop happening again. + marker.put("state", String.valueOf(STATE_PENDING)); + marker.remove("reason"); + InviteStore.write(InviteStore.PENDING, marker); + state = STATE_PENDING; + stateLoaded = true; + s = STATE_PENDING; } } if (s == STATE_RESOLVED || s == STATE_NONE_FOUND || s == STATE_DECLINED) { @@ -1415,8 +1433,9 @@ private static void beginDeferred() { // fresh install none does -- so this answer was purely in memory // and the listener heard it again on every launch, breaking the // documented once-per-install contract. - markTerminal(REASON_UNSUPPORTED); - notifyUnavailable(REASON_UNSUPPORTED); + if (markTerminal(REASON_UNSUPPORTED)) { + notifyUnavailable(REASON_UNSUPPORTED); + } return; } // Checked BEFORE the profile is created, not after. pendingRecord() @@ -1430,8 +1449,9 @@ private static void beginDeferred() { // Durable, and profile free: markTerminal replaces the record with // the state and the reason and nothing else. The reason is what // lets beginDeferred reopen this if consent is later granted. - markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED); - notifyUnavailable(REASON_CONSENT_DENIED); + if (markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED)) { + notifyUnavailable(REASON_CONSENT_DENIED); + } return; } Map pending = pendingRecord(); @@ -1446,16 +1466,18 @@ private static void beginDeferred() { if (abandonReplacement()) { return; } - markTerminal(REASON_EXPIRED); - notifyUnavailable(REASON_EXPIRED); + if (markTerminal(REASON_EXPIRED)) { + notifyUnavailable(REASON_EXPIRED); + } return; } if (InviteStore.getInt(pending, "attempts", 0) >= MAX_ATTEMPTS) { if (abandonReplacement()) { return; } - markTerminal(); - notifyUnavailable(REASON_NO_MATCH); + if (markTerminal()) { + notifyUnavailable(REASON_NO_MATCH); + } return; } setState(STATE_PENDING); @@ -1883,8 +1905,9 @@ static void handleResolution(String payload, String matchType, boolean deferred, // not enough: loadState() reads an absent record as STATE_NONE, // so the next launch built a fresh profile and asked again, and // an ordinary uninvited install re-queried the server for ever. - markTerminal(); - notifyUnavailable(REASON_NO_MATCH); + if (markTerminal()) { + notifyUnavailable(REASON_NO_MATCH); + } return; } String code = str(json.get("code")); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 88bfd39ac09..7eb379e4f82 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1335,4 +1335,48 @@ void aFailedWriteOnTheLastAttemptCanStillBeRetried() { assertTrue(InviteStore.getInt(after, "attempts", 0) < Invites.MAX_ATTEMPTS, "the promised retry could never happen: the budget was still exhausted"); } + + @Test + @EdtTest + void aDeniedDirectLinkKeepsItsCodeForTheReopening() { + // A refusal is reopenable, so the code has to survive it. Discarding it + // meant 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. + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/DENIED1"); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + + Analytics.setConsent(AnalyticsConsent.granted()); + + Map resumed = InviteStore.read(InviteStore.PENDING); + assertNotNull(resumed); + assertEquals("DENIED1", InviteStore.get(resumed, "code", null), + "the reopened lookup lost the exact code and fell back to a guess"); + } + + @FormTest + void aTerminalAnswerThatCannotBePersistedIsNotReported() { + // Reporting an outcome the device cannot remember meant the same lookup + // and the same callback repeated after every restart -- or, worse, the + // delivery flag landed on the OLD pending record and left the state at + // PENDING, so a settled lookup ran again and could never deliver. + Invites.setAttributionWindow(0); + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + InviteStore.failNextWriteForTest(InviteStore.PENDING); + Invites.checkForInvite(); + + assertEquals(0, told[0], + "an answer the device cannot remember was reported to the listener"); + assertTrue(Invites.getState() != Invites.STATE_NONE_FOUND, + "the state was committed without its marker"); + } } From abb2a1b8fe6641745dfa5f2c9b680bfc29172f8f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:31:42 +0300 Subject: [PATCH 29/70] Invites: three more places a write's result was assumed A first-time denial has no prior pending record -- that is the ordinary shape of a first launch by someone who had already refused -- so the marker copied an absent clock and carried expiresAt 0, which beginDeferred reads as "no window". An arbitrarily old install could then still run a fingerprint match after a later grant. The marker starts its own clock when there is nothing to copy. The delivery flag was written and not checked, on both sides. deliveredThisRun suppresses duplicates only until the process exits, so telling the listener about a delivery the device cannot remember means telling it again on the next launch -- against the exactly-once contract. Better late, on a launch where the flag can be written, than twice. The attempt refund's own write was unchecked, and it fails for exactly the reason the attribution write did. Nothing here can repair that, so it says so rather than leaving the promised retry to be assumed. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 54 +++++++++++++++---- .../invite/InviteResilienceTest.java | 48 +++++++++++++++++ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 6182243d67f..1bdc1edd41a 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1286,9 +1286,18 @@ private static boolean markTerminal(int terminalState, String reason) { // moment consent was granted. A user who answers the prompt a week // later would then have run a fresh fingerprint lookup and reported // invite_install for somebody else's click. + // + // Started here when there is no prior record, which is the ordinary + // shape of a first launch by someone who had already refused: nothing + // has run yet, so nothing wrote one. Copying nulls left the reopened + // marker with expiresAt 0, and beginDeferred reads that as "no window", + // so an arbitrarily old install could still run a fingerprint match. Map before = InviteStore.read(InviteStore.PENDING); - InviteStore.put(done, "firstLaunch", InviteStore.get(before, "firstLaunch", null)); - InviteStore.put(done, "expiresAt", InviteStore.get(before, "expiresAt", null)); + long markedAt = System.currentTimeMillis(); + done.put("firstLaunch", InviteStore.get(before, "firstLaunch", + String.valueOf(markedAt))); + done.put("expiresAt", InviteStore.get(before, "expiresAt", + String.valueOf(markedAt + attributionWindow))); // And the delivery state, for the same reason the resolved record // inherits it: a reopened lookup that ends terminally has still been // answered once, and dropping the flag here delivered a second @@ -1350,13 +1359,19 @@ private static String undeliveredFromMarker() { return InviteStore.get(marker, "reason", REASON_NO_MATCH); } - private static void markUnavailableDelivered() { + // Returns false when the delivery could not be recorded. Same reasoning as + // the resolved side: deliveredThisRun only suppresses duplicates until the + // process exits, so telling the listener about a delivery the device cannot + // remember means telling it again on the next launch. + private static boolean markUnavailableDelivered() { Map marker = InviteStore.read(InviteStore.PENDING); if (marker == null) { - return; + // Nothing durable to mark. The answer is still terminal in memory + // and the run's own guard prevents a repeat within it. + return true; } marker.put("delivered", "true"); - InviteStore.write(InviteStore.PENDING, marker); + return InviteStore.write(InviteStore.PENDING, marker); } private static Map pendingRecord() { @@ -2007,7 +2022,15 @@ private static void resolve(InviteAttribution a, String confidence) { if (retry != null) { int spent = InviteStore.getInt(retry, "attempts", 0); retry.put("attempts", String.valueOf(spent > 0 ? spent - 1 : 0)); - InviteStore.write(InviteStore.PENDING, retry); + if (!InviteStore.write(InviteStore.PENDING, retry)) { + // The refund failed for the same reason the attribution did + // -- the store is unwritable -- so the durable count is + // still at the cap and the next flush would settle the + // install rather than retry. Nothing here can fix that, so + // it is said out loud instead of being assumed away. + Log.p("invite: the attempt could not be refunded, so a later retry may " + + "settle this install instead of asking again", Log.WARNING); + } } Log.p("invite: the attribution could not be persisted, so the lookup stays " + "pending and will be retried", Log.WARNING); @@ -2126,9 +2149,18 @@ private static void deliverPending() { if (a == null) { return; } - deliveredThisRun = true; r.put("delivered", "true"); - InviteStore.write(InviteStore.ATTRIBUTION, r); + if (!InviteStore.write(InviteStore.ATTRIBUTION, r)) { + // deliveredThisRun only suppresses duplicates until the process + // exits, so calling the listener on a delivery the device cannot + // remember means inviteReceived() fires again on the next launch -- + // against the exactly-once contract. Better to deliver late, on a + // launch where the flag can be written, than twice. + Log.p("invite: the delivery could not be recorded, so the attribution will be " + + "delivered on a later launch instead of twice", Log.WARNING); + return; + } + deliveredThisRun = true; try { listener.inviteReceived(a); } catch (Throwable t) { @@ -2153,8 +2185,12 @@ private static void notifyUnavailable(String reason) { undelivered = reason; return; } + if (!markUnavailableDelivered()) { + Log.p("invite: the delivery could not be recorded, so this answer will be " + + "reported on a later launch instead of twice", Log.WARNING); + return; + } deliveredThisRun = true; - markUnavailableDelivered(); try { target.attributionUnavailable(reason); } catch (Throwable t) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 7eb379e4f82..edeb4f17674 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1379,4 +1379,52 @@ public void attributionUnavailable(String reason) { assertTrue(Invites.getState() != Invites.STATE_NONE_FOUND, "the state was committed without its marker"); } + + @Test + @EdtTest + void aFirstTimeDenialStartsItsOwnClock() { + // Someone who had already refused reaches this on a first launch, when + // nothing has written a pending record yet. Copying the absent clock + // left expiresAt at 0, which beginDeferred reads as "no window", so an + // arbitrarily old install could still run a fingerprint match after a + // later grant. + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + Invites.checkForInvite(); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + + Map marker = InviteStore.read(InviteStore.PENDING); + assertNotNull(marker); + assertTrue(InviteStore.getLong(marker, "expiresAt", 0) > System.currentTimeMillis(), + "the denial marker carries no window, so a reopening would have none"); + assertTrue(InviteStore.getLong(marker, "firstLaunch", 0) > 0); + } + + @FormTest + void aDeliveryThatCannotBeRecordedIsNotMadeTwice() { + // deliveredThisRun suppresses duplicates only until the process exits, + // so calling the listener on a delivery the device cannot remember + // means inviteReceived() fires again on the next launch. + Invites.handleResolution(InviteTestSupport.resolvedJson("ONCE1", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + final int[] received = new int[1]; + InviteListener l = new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0]++; + } + + public void attributionUnavailable(String reason) { + } + }; + InviteStore.failNextWriteForTest(InviteStore.ATTRIBUTION); + Invites.setInviteListener(l); + assertEquals(0, received[0], + "the listener was told about a delivery the device cannot remember"); + + // A later launch, with storage working, delivers it exactly once. + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + Invites.setInviteListener(l); + assertEquals(1, received[0], "the attribution was never delivered at all"); + } } From 883f9aa9fcf419fb7b2adf9b1aabf84f876263ce Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:24:58 +0300 Subject: [PATCH 30/70] Invites: reopen with a usable window and a real device profile Two ways a reopened deferred lookup came back unable to answer, plus the Android delivery that never reached the invite code at all. A terminal marker deliberately carries no device profile -- a refusal deletes the fingerprint, which is the promise the consent path makes -- so converting one back to pending left the resumed match sending empty strings and zero screen dimensions. The server then had the network and the country to score on, which is below the threshold: a consent grant inside the original window could not recover the invite it was granted for. The profile is now CAPTURED AGAIN on reopening rather than carried through the refusal, which keeps both promises. The kill-switch reopening had a second problem the first could hide. A marker written while setAttributionWindow(0) was in force recorded expiresAt = firstLaunch + 0, a window already over at the instant it was created; reopening kept it and the expiry check settled the lookup again on the same pass. Shipping a non-zero window later -- the documented way to ask again -- could therefore never work. firstLaunch is a fact about the install and stays; the window is a policy and the current one now applies. The consent reopening is untouched: its marker was written under a real window and recomputing there would change a right answer. reenablingTheWindowReopensThatOneTerminalMarker was already asserting this and passed anyway, because whether the state assertion catches it depends on which sibling test ran first -- it fails on its own on master. It now asserts the expiry on the record. On Android, an App Link that arrives while the activity is resumed never reaches the application's start(): the generated lifecycle returns early when wasStopped is false, and the next onStop() clears the app arg the port just stored. The invite was lost with nothing to show for it -- no claim, no invite_opened. The stub now overrides onNewIntent and consumes it on the EDT. Generated rather than done in the port, because AndroidImplementation referencing com.codename1.analytics.invite would make PlatformFeatureCatalog match the prefix for every application and put a Play Install Referrer dependency and an API 21 floor on apps that never heard of invites -- the DatabaseConfig bug, already fixed once. AndroidInviteNewIntentTest pins that gating along with the override. --- .../codename1/analytics/invite/Invites.java | 66 +++++++++-- .../builders/AndroidGradleBuilder.java | 29 +++++ .../builders/AndroidInviteNewIntentTest.java | 106 ++++++++++++++++++ .../invite/InviteResilienceTest.java | 47 ++++++++ 4 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 1bdc1edd41a..8e9dc79a4a2 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1385,19 +1385,35 @@ private static Map pendingRecord() { pending.put("expiresAt", String.valueOf(now + attributionWindow)); pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); + captureProfile(pending); + InviteStore.write(InviteStore.PENDING, pending); + return pending; + } + + /// Writes the coarse device profile the deferred lookup is matched on. + /// + /// Separate from `pendingRecord()` because it is needed twice. A terminal + /// marker deliberately carries none of it -- a refused profile is deleted, + /// which is the promise the consent path makes -- so a marker that is + /// later reopened has to capture it again rather than restore it. Sending + /// the empty strings and zero dimensions the terminal marker really does + /// hold left the server with the network and the country and nothing else, + /// which scores below the threshold: a consent grant inside the original + /// window could not recover the invite it was granted for. + /// + /// - `record`: the pending record to fill in + private static void captureProfile(Map record) { Display d = Display.getInstance(); if (d != null) { - InviteStore.put(pending, "platform", d.getPlatformName()); - InviteStore.put(pending, "osVersion", d.getProperty("OSVer", "")); - InviteStore.put(pending, "deviceModel", + InviteStore.put(record, "platform", d.getPlatformName()); + InviteStore.put(record, "osVersion", d.getProperty("OSVer", "")); + InviteStore.put(record, "deviceModel", d.getProperty("DeviceHardwareModel", d.getProperty("DeviceName", ""))); - pending.put("screenWidth", String.valueOf(d.getDisplayWidth())); - pending.put("screenHeight", String.valueOf(d.getDisplayHeight())); + record.put("screenWidth", String.valueOf(d.getDisplayWidth())); + record.put("screenHeight", String.valueOf(d.getDisplayHeight())); } Locale loc = Locale.getDefault(); - InviteStore.put(pending, "locale", loc == null ? "" : loc.toString()); - InviteStore.write(InviteStore.PENDING, pending); - return pending; + InviteStore.put(record, "locale", loc == null ? "" : loc.toString()); } private static void beginDeferred() { @@ -1427,7 +1443,41 @@ private static void beginDeferred() { // one review round at a time, which is what this shape exists // to stop happening again. marker.put("state", String.valueOf(STATE_PENDING)); + if (REASON_UNSUPPORTED.equals(why)) { + // The window is recomputed for THIS reopening, and only + // this one. + // + // A marker written while the kill switch was on recorded + // expiresAt = firstLaunch + 0, so its window was already + // over at the instant it was created. Reopening it kept + // that zero-length window, the expiry check below settled + // the lookup again as "expired" on the same pass, and + // shipping a non-zero window later -- the documented way to + // ask again -- could therefore never work. + // + // firstLaunch is a fact about this install and stays; the + // window is a policy the application sets and the current + // one applies. The consent reopening is left alone: its + // marker was written under a real window, and recomputing + // there would change a value that is already right. + long began = InviteStore.getLong(marker, "firstLaunch", + System.currentTimeMillis()); + marker.put("expiresAt", String.valueOf(began + attributionWindow)); + } marker.remove("reason"); + // And the device profile is CAPTURED AGAIN, not restored. + // + // markTerminal() carries the timing, the delivery flag and the + // direct-link code and nothing that describes the device -- + // deliberately, because a refusal deletes the fingerprint. So + // the marker being converted here holds none of it, and the + // resumed requestMatch() sent empty strings and zero screen + // dimensions: the server had the network and the country to + // score on, which is not enough to match, so granting consent + // inside the original window recovered nothing. Recapturing + // costs five property reads and is the same profile the first + // launch would have taken. + captureProfile(marker); InviteStore.write(InviteStore.PENDING, marker); state = STATE_PENDING; stateLoaded = true; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 62361656ac8..6baa2a3fd64 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -6005,6 +6005,34 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { } } + // An App Link that arrives while the activity is already resumed never + // reaches the application's start(). The lifecycle generated below + // returns early when wasStopped is false -- it just re-shows the + // current form -- so the documented checkForInvite() call in start() + // does not run, and the app arg the port stored a moment ago is cleared + // again by the next onStop(). The invite is silently lost: no claim, no + // invite_opened, for a delivery that worked perfectly. + // + // Generated rather than done in the port, because AndroidImplementation + // referencing the invite package would make PlatformFeatureCatalog + // match it for EVERY application -- a Play Install Referrer dependency + // and an API 21 floor on apps that never heard of invites. This splice + // lands only in an app whose classes actually use them, which is the + // same condition the registration above rides on. + String inviteNewIntent = ""; + if (usesInvites) { + inviteNewIntent = " protected void onNewIntent(android.content.Intent intent) {\n" + + " super.onNewIntent(intent);\n" + + " if(!Display.isInitialized()) {\n" + + " return;\n" + + " }\n" + + " Display.getInstance().callSerially(new Runnable() {\n" + + " public void run() {\n" + + " com.codename1.analytics.invite.Invites.checkForInvite();\n" + + " }\n" + + " });\n" + + " }\n\n"; + } String inviteRegisterInstall = ""; if (usesInvites) { inviteRegisterInstall = " com.codename1.analytics.invite.Invites" @@ -6379,6 +6407,7 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { + " currentForm = null;\n" + " }\n" + " }\n\n" + + inviteNewIntent + " protected void onPause() {\n" + " super.onPause();\n" + " synchronized(LOCK) {\n" diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java new file mode 100644 index 00000000000..43d0db75ecf --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java @@ -0,0 +1,106 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * An App Link delivered to a resumed activity has to reach the invite code. + * + *

{@code onNewIntent} stores the url in {@code AppArg} and the port stops + * there. The generated lifecycle returns early when {@code wasStopped} is false + * -- it re-shows the current form and nothing else -- so the documented + * {@code checkForInvite()} call in the application's {@code start()} never + * runs, and the next {@code onStop()} clears the property again. The delivery + * worked and the invite was silently lost: no claim, no {@code invite_opened}. + * The stub therefore overrides {@code onNewIntent} and consumes it.

+ * + *

Generated rather than done in the port, and that is the load-bearing part: + * {@code AndroidImplementation} referencing {@code com.codename1.analytics.invite} + * would make {@code PlatformFeatureCatalog} match the prefix for EVERY + * application, putting a Play Install Referrer dependency and an API 21 floor + * on apps that never heard of invites. That is the {@code DatabaseConfig} bug, + * already fixed once. So the reference may only exist inside code emitted for + * an app whose own classes use invites.

+ * + *

Asserted against the builder's source text, as {@code StubLifecycleCastTest} + * does: the stub is assembled inline across a few hundred lines with no seam to + * call, and what has to stay true is a property of the assembly.

+ */ +public class AndroidInviteNewIntentTest { + + private static final String BUILDER = + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java"; + + private String source() throws IOException { + File builder = new File(BUILDER); + assertTrue(builder.isFile(), "the builder must be readable: " + builder.getAbsolutePath()); + return new String(Files.readAllBytes(builder.toPath()), StandardCharsets.UTF_8); + } + + @Test + void theStubOverridesOnNewIntentAndConsumesTheInvite() throws IOException { + String source = source(); + int declared = source.indexOf("String inviteNewIntent = \"\";"); + assertTrue(declared > 0, "the onNewIntent splice is gone, so a resumed App Link is lost"); + assertTrue(source.contains("protected void onNewIntent(android.content.Intent intent)"), + "the generated stub no longer overrides onNewIntent"); + assertTrue(source.contains("com.codename1.analytics.invite.Invites.checkForInvite();"), + "the generated onNewIntent no longer consumes the invite"); + assertTrue(source.contains("+ inviteNewIntent"), + "the splice is built and never emitted into the stub"); + } + + @Test + void theOverrideRunsOnTheEventDispatchThread() throws IOException { + String source = source(); + int splice = source.indexOf("String inviteNewIntent = \"\";"); + int end = source.indexOf("String inviteRegisterInstall", splice); + assertTrue(splice > 0 && end > splice, "the splice block moved"); + String block = source.substring(splice, end); + // onNewIntent runs on Android's UI thread, not the Codename One EDT. + assertTrue(block.contains("Display.getInstance().callSerially("), + "the generated onNewIntent touches invite state off the EDT"); + assertTrue(block.contains("if(!Display.isInitialized())"), + "the generated onNewIntent can run before Display exists"); + } + + @Test + void theInviteReferenceOnlyExistsForAppsThatUseInvites() throws IOException { + String source = source(); + int splice = source.indexOf("String inviteNewIntent = \"\";"); + int gate = source.indexOf("if (usesInvites) {", splice); + int body = source.indexOf("com.codename1.analytics.invite.Invites.checkForInvite();", + splice); + assertTrue(gate > splice && gate < body, + "the onNewIntent splice is emitted for every app, which puts a Play Install " + + "Referrer dependency and an API 21 floor on all of them"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index edeb4f17674..75aaddbb952 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -229,6 +229,18 @@ void reenablingTheWindowReopensThatOneTerminalMarker() { Invites.checkForInvite(); assertEquals(Invites.STATE_PENDING, Invites.getState(), "a re-enabled window did not reopen the lookup"); + // And the window it was reopened with is a real one. The marker was + // written while the kill switch was on, so it recorded + // expiresAt = firstLaunch + 0 -- a window already over at the instant + // it was created. Reopening kept it, the expiry check settled the + // lookup again on the same pass, and shipping a non-zero window later + // could never work. Asserted on the record rather than only through the + // state, because whether the state assertion above catches it depends + // on which other test in this class ran first. + Map reopened = InviteStore.read(InviteStore.PENDING); + assertNotNull(reopened); + assertTrue(InviteStore.getLong(reopened, "expiresAt", 0) > System.currentTimeMillis(), + "the reopened lookup carries the kill switch's zero-length window"); } @Test @@ -1036,6 +1048,41 @@ void reopeningAfterConsentKeepsTheOriginalWindow() { "granting consent restarted the attribution window"); } + @Test + @EdtTest + void reopeningAfterConsentCapturesTheDeviceProfileAgain() { + // The other half of the same reopen. markTerminal() carries the timing, + // the delivery flag and the direct-link code and nothing that describes + // the device -- deliberately, because a refusal deletes the + // fingerprint. So the marker converted back to pending held empty + // strings and zero screen dimensions, and the resumed match sent the + // server the network and the country to score on and nothing else, + // which is below the threshold. Granting consent inside the original + // window could not recover the invite it was granted for. + Invites.checkForInvite(); + Map first = InviteStore.read(InviteStore.PENDING); + assertNotNull(first); + String platform = InviteStore.get(first, "platform", ""); + assertTrue(platform.length() > 0, "the first launch captured no platform"); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + Map denied = InviteStore.read(InviteStore.PENDING); + assertNotNull(denied); + assertEquals("", InviteStore.get(denied, "platform", ""), + "the refused marker kept a device profile it promised to delete"); + + Analytics.setConsent(AnalyticsConsent.granted()); + + Map resumed = InviteStore.read(InviteStore.PENDING); + assertNotNull(resumed); + assertEquals(platform, InviteStore.get(resumed, "platform", ""), + "the reopened lookup carries no platform, so it cannot match"); + assertTrue(InviteStore.getLong(resumed, "screenWidth", 0) > 0, + "the reopened lookup carries no screen dimensions"); + assertTrue(InviteStore.get(resumed, "locale", "").length() > 0, + "the reopened lookup carries no locale"); + } + @Test @EdtTest void theZeroWindowDoesNotDiscardAnExactCodeWeAreHolding() { From 242bad2106e58139bbbe3e37ffa442b13db1c13b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:59:18 +0300 Subject: [PATCH 31/70] Invites: a failed pending write no longer loses what it was writing Storage can fail, and every caller here had already changed the in-memory state by the time it did. handleUrl() was the worst case: it committed STATE_PENDING and issued the claim before knowing the record reached the disk, so when the write failed and the claim failed too, the exact code from a direct link existed nowhere. The retry then read the stale record underneath it -- which has no code -- and answered with the install referrer or a fingerprint instead: a guess, or nothing, for a question the device had an exact answer to. Every read and write of the pending record now goes through readPending() and writePending(). A failed write keeps its record in memory and the next read prefers it -- it is always the newer of the two, because it exists only between a write that failed and the next one that succeeds -- and retries persisting it there, which is the next time anything wanted the record anyway. Successful writes and every delete clear it, so it can never shadow the disk. It does not survive the process, and cannot; that is what the durable record is for. A transient failure is over within one launch far more often than not. Separately, create() failed to mark the code unacknowledged on the branch where the outbox write failed AND consent forbade sending. isRegistered() reads absence from both the outbox and that set as acknowledgement, so the one invite the server is guaranteed never to have seen was the one reported as registered -- and an application that waits for it before sharing hands out a link with no campaign, channel or preview behind it. Both tests were checked by reverting their fix. The pending-write one arms the failure AFTER the record exists, so the write that fails is the one adding the code rather than the one creating the record: that is the case a stale disk record can shadow, and the one the earlier draft of this test missed entirely by passing without the fix. --- .../codename1/analytics/invite/Invites.java | 145 ++++++++++++++---- .../invite/InviteResilienceTest.java | 56 +++++++ 2 files changed, 175 insertions(+), 26 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 8e9dc79a4a2..9965437685e 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -221,6 +221,24 @@ public final class Invites { private static boolean deferredStarted; + /// The pending record that could not be written, held until it can be. + /// + /// `Storage` can fail -- a full disk, a revoked sandbox -- and every + /// caller here had already changed the in-memory state by the time it did. + /// A direct link was the worst case: `handleUrl` committed STATE_PENDING + /// and issued the claim, and if that request also failed, the exact code + /// existed nowhere. The retry then read a record with no code in it and + /// fell back to the install referrer or the fingerprint -- answering a + /// question the device already had an exact answer to, with a guess or not + /// at all. + /// + /// Held only while the durable copy is missing: a successful write clears + /// it, so this can never disagree with what is on the disk. It does not + /// survive the process, and cannot -- that is what the durable record is + /// for -- but a transient failure is over within one launch far more often + /// than not. + private static Map pendingFallback; + // When the last claim or match was issued. flush() restarts only once this // has aged out: retrying a request that is still outstanding spends an // attempt without a failure having been observed, and the attempt budget is @@ -303,6 +321,14 @@ public static Invite create(InviteRequest request) { unacknowledged.add(invite.getCode()); postRegistration(pendingRegistration); } else { + // Marked unacknowledged, exactly as the branch above does. + // + // isRegistered() reads absence from BOTH the outbox and this + // set as acknowledgement, and neither holds this code: the + // outbox write is what failed, and nothing was sent. So the + // one invite the server is guaranteed never to have seen was + // the one reported as registered. + unacknowledged.add(invite.getCode()); // Nothing leaves the device without consent, and that outranks // saving the registration. drainOutbox() carries the same guard; // this path had none, so a storage failure was the one way an @@ -546,7 +572,7 @@ public static boolean handleUrl(String url) { // written, so without this there is nothing for markTerminal to // carry, and a user who grants consent afterwards has the exact // claim replaced by a referrer read or a statistical match. - Map denied = InviteStore.read(InviteStore.PENDING); + Map denied = readPending(); if (denied == null) { denied = new LinkedHashMap(); } @@ -554,7 +580,7 @@ public static boolean handleUrl(String url) { denied.put("codeSource", "universal_link"); denied.put("codeMatch", MATCH_DIRECT); denied.put("codeDeferred", "false"); - InviteStore.write(InviteStore.PENDING, denied); + writePending(denied); // Told, not silently dropped. checkForInvite() records the url as // consumed and skips the deferred path after this, so this is the // only chance the listener gets for this install -- and a @@ -599,7 +625,7 @@ public static boolean handleUrl(String url) { // are holding the code for, so a referrer read is no longer a better // answer waiting to happen. pending.remove("referrerRetry"); - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); setState(STATE_PENDING); // A deferred fingerprint or referrer lookup may already be on the wire, // and this direct claim supersedes it. Without the bump both answers @@ -649,6 +675,11 @@ static void forgetLoadedState() { state = STATE_NONE; deferredStarted = false; deliveredThisRun = false; + // The in-memory pending copy goes with the rest of the loaded state. + // Keeping it made "forget what you loaded" leave behind the one record + // that had never reached the disk, so a test -- or an application + // deliberately re-reading -- saw a record no launch could ever see. + pendingFallback = null; } /// Where attribution has got to: one of the `STATE_` constants. @@ -671,7 +702,7 @@ private static void loadState() { return; } stateLoaded = true; - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); // Reduced to a value first. The obvious spelling -- null-check the // record inside the condition, then assign the static below it -- is // the shape PMD reads as an unsynchronized lazy singleton, and the @@ -876,6 +907,7 @@ public static void flush() { public static void reset() { lookupEpoch++; InviteStore.delete(InviteStore.PENDING); + forgetPendingFallback(); InviteStore.delete(InviteStore.ATTRIBUTION); InviteStore.delete(InviteStore.OUTBOX); Preferences.delete(PREF_CONSUMED_ARG); @@ -1224,10 +1256,10 @@ private static void putIfSet(Map p, String key, String value) { private static void setState(int s) { state = s; stateLoaded = true; - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); if (pending != null) { pending.put("state", String.valueOf(s)); - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); } } @@ -1249,6 +1281,7 @@ private static boolean abandonReplacement() { return false; } InviteStore.delete(InviteStore.PENDING); + forgetPendingFallback(); state = STATE_RESOLVED; stateLoaded = true; deferredStarted = false; @@ -1257,7 +1290,7 @@ private static boolean abandonReplacement() { } private static boolean hasSavedCode() { - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); String code = InviteStore.get(pending, "code", null); return code != null && code.length() > 0; } @@ -1292,7 +1325,7 @@ private static boolean markTerminal(int terminalState, String reason) { // has run yet, so nothing wrote one. Copying nulls left the reopened // marker with expiresAt 0, and beginDeferred reads that as "no window", // so an arbitrarily old install could still run a fingerprint match. - Map before = InviteStore.read(InviteStore.PENDING); + Map before = readPending(); long markedAt = System.currentTimeMillis(); done.put("firstLaunch", InviteStore.get(before, "firstLaunch", String.valueOf(markedAt))); @@ -1327,7 +1360,7 @@ private static boolean markTerminal(int terminalState, String reason) { "codeReferrer"}) { InviteStore.put(done, key, InviteStore.get(before, key, null)); } - if (!InviteStore.write(InviteStore.PENDING, done)) { + if (!writePending(done)) { // Nothing is committed. Reporting a terminal outcome the device // cannot remember meant the same lookup and the same callback // repeated after every restart -- or, worse, the delivery flag @@ -1352,7 +1385,7 @@ private static String undeliveredFromMarker() { if (s != STATE_NONE_FOUND && s != STATE_DECLINED) { return null; } - Map marker = InviteStore.read(InviteStore.PENDING); + Map marker = readPending(); if (marker == null || InviteStore.getBoolean(marker, "delivered", false)) { return null; } @@ -1364,18 +1397,76 @@ private static String undeliveredFromMarker() { // process exits, so telling the listener about a delivery the device cannot // remember means telling it again on the next launch. private static boolean markUnavailableDelivered() { - Map marker = InviteStore.read(InviteStore.PENDING); + Map marker = readPending(); if (marker == null) { // Nothing durable to mark. The answer is still terminal in memory // and the run's own guard prevents a repeat within it. return true; } marker.put("delivered", "true"); - return InviteStore.write(InviteStore.PENDING, marker); + return writePending(marker); + } + + /// Writes the pending record, keeping an in-memory copy while that fails. + /// + /// - `record`: the record to persist + /// + /// #### Returns + /// + /// true when it reached storage + private static boolean writePending(Map record) { + boolean written = InviteStore.write(InviteStore.PENDING, record); + // Cleared on success rather than left behind, so the fallback can never + // shadow a newer durable record. + pendingFallback = written ? null : record; + return written; + } + + /// Forgets the in-memory copy, for the paths that delete the record. + private static void forgetPendingFallback() { + pendingFallback = null; + } + + /// Reads the pending record, preferring the copy a failed write left behind. + /// + /// The held copy is always the newer of the two, because it exists only + /// between a write that failed and the next one that succeeds -- so the + /// record still on the disk is whatever was there BEFORE the change that + /// could not be saved. Reading the disk first was the shape of the bug this + /// exists to close: a direct link's exact code was written into a record + /// that never landed, the stale one underneath it had no code, and the + /// retry answered with the install referrer or a fingerprint instead. + /// + /// Persisting is retried here rather than on a timer, which is the next + /// time anything wanted the record anyway. + /// + /// #### Returns + /// + /// the record, or null when there is none + /// The pending record as the feature itself sees it, for tests. + /// + /// Package private: the tests have to be able to tell the durable record + /// apart from the copy held after a failed write, and going through + /// `InviteStore` directly cannot. + /// + /// #### Returns + /// + /// the record, or null + static Map pendingRecordForTest() { + return readPending(); + } + + private static Map readPending() { + Map held = pendingFallback; + if (held != null) { + writePending(held); + return held; + } + return InviteStore.read(InviteStore.PENDING); } private static Map pendingRecord() { - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); if (pending != null) { return pending; } @@ -1386,7 +1477,7 @@ private static Map pendingRecord() { pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); captureProfile(pending); - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); return pending; } @@ -1428,7 +1519,7 @@ private static void beginDeferred() { // was a real answer about this install and stays. Reopening reads the // condition itself, never a second stored copy of it. if (s == STATE_NONE_FOUND || s == STATE_DECLINED) { - Map marker = InviteStore.read(InviteStore.PENDING); + Map marker = readPending(); String why = InviteStore.get(marker, "reason", null); boolean reopen = (REASON_UNSUPPORTED.equals(why) && attributionWindow != 0) || (REASON_CONSENT_DENIED.equals(why) && !explicitlyDenied()); @@ -1478,7 +1569,7 @@ private static void beginDeferred() { // costs five property reads and is the same profile the first // launch would have taken. captureProfile(marker); - InviteStore.write(InviteStore.PENDING, marker); + writePending(marker); state = STATE_PENDING; stateLoaded = true; s = STATE_PENDING; @@ -1635,7 +1726,7 @@ public void run() { InviteStore.put(pending, "codeReferrer", rawReferrer == null ? "" : rawReferrer); pending.remove("referrerRetry"); - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); claim(code, "install_referrer", rawReferrer == null ? "" : rawReferrer, MATCH_REFERRER, true); @@ -1692,7 +1783,7 @@ private static void fallBackToMatch() { // later, so a no-match from the statistical fallback stays pending instead // of becoming the final word. private static void fallBackToMatch(boolean retryable) { - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); if (pending != null) { if (retryable) { pending.put("referrerRetry", "true"); @@ -1704,13 +1795,13 @@ private static void fallBackToMatch(boolean retryable) { // pending and every launch asked again until the attempt cap. pending.remove("referrerRetry"); } - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); } fallBackToMatchImpl(); } private static void fallBackToMatchImpl() { - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); if (pending == null) { return; } @@ -1751,7 +1842,7 @@ private static void claim(String code, String source, String rawReferrer, if (!allowed()) { return; } - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); if (pending != null) { bumpAttempts(pending); } @@ -1766,7 +1857,7 @@ private static void claim(String code, String source, String rawReferrer, private static void bumpAttempts(Map pending) { pending.put("attempts", String.valueOf(InviteStore.getInt(pending, "attempts", 0) + 1)); - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); } private static Map identity() { @@ -1928,7 +2019,7 @@ static void handleResolution(String payload, String matchType, boolean deferred, // throw that away for a statistical guess. Bounded by the // attempt cap and the attribution window, both checked in // beginDeferred(). - Map outstanding = InviteStore.read(InviteStore.PENDING); + Map outstanding = readPending(); if (outstanding != null && "true".equals(InviteStore.get(outstanding, "referrerRetry", null))) { // Deliberately silent. attributionUnavailable() is the @@ -1960,6 +2051,7 @@ static void handleResolution(String payload, String matchType, boolean deferred, // replacement attempt is dropped and the install goes back // to what it was. InviteStore.delete(InviteStore.PENDING); + forgetPendingFallback(); state = STATE_RESOLVED; stateLoaded = true; deferredStarted = false; @@ -2047,7 +2139,7 @@ private static void resolve(InviteAttribution a, String confidence) { // record that carried the fact across the reopen. Map previous = InviteStore.read(InviteStore.ATTRIBUTION); if (previous == null) { - previous = InviteStore.read(InviteStore.PENDING); + previous = readPending(); } record.put("delivered", String.valueOf(InviteStore.getBoolean(previous, "delivered", false))); @@ -2068,11 +2160,11 @@ private static void resolve(InviteAttribution a, String confidence) { // install terminal instead of performing the retry this promises -- // so the very last response, the one most likely to be the only one // left, could never be stored. - Map retry = InviteStore.read(InviteStore.PENDING); + Map retry = readPending(); if (retry != null) { int spent = InviteStore.getInt(retry, "attempts", 0); retry.put("attempts", String.valueOf(spent > 0 ? spent - 1 : 0)); - if (!InviteStore.write(InviteStore.PENDING, retry)) { + if (!writePending(retry)) { // The refund failed for the same reason the attribution did // -- the store is unwritable -- so the durable count is // still at the cap and the next flush would settle the @@ -2087,6 +2179,7 @@ private static void resolve(InviteAttribution a, String confidence) { return; } InviteStore.delete(InviteStore.PENDING); + forgetPendingFallback(); resolved = a; attributionLoaded = true; state = STATE_RESOLVED; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 75aaddbb952..3fcb6552ded 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -515,6 +515,62 @@ void aFailedOutboxWriteStillTransmitsNothingWithoutConsent() { "a registration was transmitted before consent was given"); } + @FormTest + void anInviteTheServerNeverSawIsNotReportedAsRegistered() { + // isRegistered() reads absence from BOTH the outbox and the in-memory + // unacknowledged set as acknowledgement. On this path neither holds the + // code -- the outbox write is what failed, and consent forbade sending + // -- so the one invite the server is guaranteed never to have seen was + // the one reported as registered, and an application that waits for + // isRegistered() before sharing would hand out a link with no campaign, + // channel or preview behind it. + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + InviteStore.failNextOutboxWriteForTest(); + + Invite invite = Invites.create(InviteRequest.create().campaign("launch").build()); + assertNotNull(invite, "minting is offline and must still work"); + assertEquals(0, implementation.getQueuedRequests().size(), + "a registration was transmitted before consent was given"); + assertFalse(Invites.isRegistered(invite), + "an invite that was neither queued nor sent reported itself registered"); + } + + @FormTest + void aFailedPendingWriteDoesNotLoseTheDirectCode() { + // handleUrl() commits STATE_PENDING and issues the claim before it + // knows the record reached the disk. When the write failed and the + // claim failed too, the exact code existed nowhere: the retry read a + // record with no code in it and fell back to the install referrer or + // the fingerprint -- answering with a guess, or not at all, a question + // the device had an exact answer to. The copy is held in memory until a + // write succeeds, which the next read of the record retries. + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + // The record has to EXIST first, so the failing write is the one that + // adds the code rather than the one that creates the record. That is + // also the harder case: a stale record with no code in it sits on the + // disk underneath the copy that never landed, and reading the disk + // first found it and answered with a guess. + Invites.checkForInvite(); + assertNull(InviteStore.get(InviteStore.read(InviteStore.PENDING), "code", null), + "the fixture already has a code, so the assertion below proves nothing"); + InviteStore.failNextWriteForTest(InviteStore.PENDING); + + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT7"), + "the link was not recognised at all"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + // And the next read of the record still has the code, and persists it. + Map record = Invites.pendingRecordForTest(); + assertNotNull(record, "the failed write was never retried"); + assertEquals("DIRECT7", InviteStore.get(record, "code", null), + "the exact code was lost, so the retry will guess instead"); + assertEquals(Invites.MATCH_DIRECT, InviteStore.get(record, "codeMatch", null), + "the direct claim lost its provenance"); + } + @Test @EdtTest void flushDoesNotSpendAnAttemptOnALookupThatIsStillOutstanding() { From 1dcfc6b9988a8b3113e5060b8fa137cc8a758179 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:31:10 +0300 Subject: [PATCH 32/70] Invites: two ways a registration said yes when the answer was no A regression from the pending-record fallback, found by review before it shipped. markUnavailableDelivered() puts delivered=true on the marker and the caller withholds the callback when the write fails -- but the map carrying that flag is the one the failure holds for retry, so the next read persisted the very flag the failure was meant to prevent. The answer then read as already delivered and the listener never heard it, on that launch or any other. The flag is backed out of the map on failure, which is the whole restore because the method returns early when it was already set. Separately, the outbox is capped at 512 and drops the OLDEST entry to stay under it. isRegistered() reads absence from both the outbox and the unacknowledged set as acknowledgement, and an evicted entry is in neither -- so the one registration the server is guaranteed never to have received reported itself as registered, and only a log line said otherwise. InviteStore now hands each evicted entry to Invites before dropping it. In memory only, like every other entry in that set; the ERROR log remains the durable half. The cap moved out of writeOutbox's try block to do it: copy.remove(0) on a List compiles to a CHECKCAST, ParparVM does not throw for a failed cast, and check-cast-semantics.sh refuses a checked cast under a catch(Throwable) because the handler cannot run on iOS. Nothing in the cap can fail anyway -- a copy, a size comparison and a removal. Both tests were checked by reverting their fix. --- .../analytics/invite/InviteStore.java | 33 +++++--- .../codename1/analytics/invite/Invites.java | 78 ++++++++++++++++--- .../invite/InviteResilienceTest.java | 74 ++++++++++++++++++ 3 files changed, 163 insertions(+), 22 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index 158a8ada13e..ec6388a8b6d 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -179,21 +179,34 @@ static boolean writeOutbox(List entries) { failNextWrite = false; return false; } + // The cap is applied OUTSIDE the try, deliberately. + // + // copy.remove(0) on a List compiles to a CHECKCAST, and + // ParparVM does not throw for a failed cast -- so a checked cast inside + // a catch(Throwable) is a handler that cannot run on iOS, which + // check-cast-semantics.sh refuses outright. Nothing here can fail + // anyway: it is a copy, a size comparison and a removal. + List copy = new ArrayList(entries); + int dropped = 0; + while (copy.size() > MAX_OUTBOX) { + // Reported to Invites before it goes, so isRegistered() can keep + // saying no about it. That method reads absence from BOTH the + // outbox and the unacknowledged set as acknowledgement, and an + // evicted entry is in neither -- so the one registration the server + // is guaranteed never to have received was reported as registered, + // and only the log below said otherwise. + Invites.registrationEvicted(copy.remove(0)); + dropped++; + } + if (dropped > 0) { + Log.p("invite: dropped " + dropped + " unacknowledged registration(s); " + + "those invite links can no longer be attributed", Log.ERROR); + } try { Storage s = Storage.getInstance(); if (s == null) { return false; } - List copy = new ArrayList(entries); - int dropped = 0; - while (copy.size() > MAX_OUTBOX) { - copy.remove(0); - dropped++; - } - if (dropped > 0) { - Log.p("invite: dropped " + dropped + " unacknowledged registration(s); " - + "those invite links can no longer be attributed", Log.ERROR); - } if (copy.isEmpty()) { if (s.exists(OUTBOX)) { s.deleteStorageFile(OUTBOX); diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 9965437685e..c8bc826877b 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1361,12 +1361,18 @@ private static boolean markTerminal(int terminalState, String reason) { InviteStore.put(done, key, InviteStore.get(before, key, null)); } if (!writePending(done)) { - // Nothing is committed. Reporting a terminal outcome the device - // cannot remember meant the same lookup and the same callback - // repeated after every restart -- or, worse, the delivery flag - // landed on the OLD pending record and left the state at PENDING, - // so a supposedly settled lookup ran again and could never deliver - // its answer. + // Not reported now. Reporting a terminal outcome the device cannot + // remember meant the same lookup and the same callback repeated + // after every restart -- or, worse, the delivery flag landed on the + // OLD pending record and left the state at PENDING, so a supposedly + // settled lookup ran again and could never deliver its answer. + // + // The record is held by writePending() and persisted by the next + // read, so the answer is not lost, only deferred: this run says + // nothing and the marker is read back as an undelivered terminal + // answer afterwards, which is what the contract promises. The state + // is deliberately not set in memory either, so nothing here acts on + // a record that may still be only in memory. Log.p("invite: a terminal answer could not be persisted; it will be reached " + "again rather than reported now", Log.WARNING); return false; @@ -1403,8 +1409,24 @@ private static boolean markUnavailableDelivered() { // and the run's own guard prevents a repeat within it. return true; } + if (InviteStore.getBoolean(marker, "delivered", false)) { + return true; + } marker.put("delivered", "true"); - return writePending(marker); + if (writePending(marker)) { + return true; + } + // Backed out of the map, not only reported. + // + // The caller withholds the callback when this returns false, so the + // record must not go on claiming the answer was delivered -- and the + // fallback holds THIS map. Left as it is, the next readPending() would + // persist the very flag the failed write was supposed to prevent, + // undeliveredFromMarker() would then read the answer as already given, + // and the listener would never hear it on any launch. Removing is the + // whole restore because the early return above means it was absent. + marker.remove("delivered"); + return false; } /// Writes the pending record, keeping an in-memory copy while that fails. @@ -2165,11 +2187,13 @@ private static void resolve(InviteAttribution a, String confidence) { int spent = InviteStore.getInt(retry, "attempts", 0); retry.put("attempts", String.valueOf(spent > 0 ? spent - 1 : 0)); if (!writePending(retry)) { - // The refund failed for the same reason the attribution did - // -- the store is unwritable -- so the durable count is - // still at the cap and the next flush would settle the - // install rather than retry. Nothing here can fix that, so - // it is said out loud instead of being assumed away. + // The refund failed for the same reason the attribution + // did -- the store is unwritable -- so the count ON DISK is + // still at the cap. writePending() holds the refunded copy + // and the next read persists it, so a retry within this + // launch sees the right number; a restart before that does + // not, and settles the install rather than asking again. + // Said out loud rather than assumed away. Log.p("invite: the attempt could not be refunded, so a later retry may " + "settle this install instead of asking again", Log.WARNING); } @@ -2354,6 +2378,36 @@ private static void notifyUnavailable(String reason) { // the durable store is the thing that just failed. private static final List unacknowledged = new ArrayList(); + /// Records that a queued registration was evicted to keep the outbox + /// under its cap. + /// + /// The entry is gone for good -- its campaign, channel, payload and + /// preview cannot be reconstructed from a click -- so the least this can + /// do is stop [#isRegistered] answering yes about it. In memory only, like + /// every other entry in that set: after a restart the outbox is the only + /// record, and the evicted entry is not in it. The ERROR logged by the + /// caller is the durable half. + /// + /// - `entry`: the registration JSON that was dropped + static void registrationEvicted(String entry) { + if (entry == null) { + return; + } + try { + Map parsed = + new JSONParser().parseJSON(new java.io.StringReader(entry)); + Object code = parsed == null ? null : parsed.get("code"); + if (code != null) { + unacknowledged.add(code.toString()); + } + } catch (Throwable t) { + // A malformed entry is already lost; failing here would take the + // whole write with it, and the write is what keeps the REST of the + // queue. + Log.e(t); + } + } + private static boolean queueRegistration(Invite invite, InviteRequest request) { Map body = identity(); body.put("code", invite.getCode()); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 3fcb6552ded..6ab80b9254b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -537,6 +537,33 @@ void anInviteTheServerNeverSawIsNotReportedAsRegistered() { "an invite that was neither queued nor sent reported itself registered"); } + @FormTest + void anEvictedRegistrationIsNotReportedAsRegistered() { + // The outbox is capped, and the cap drops the OLDEST entry. isRegistered() + // reads absence from both the outbox and the unacknowledged set as + // acknowledgement, and an evicted entry is in neither -- so the one + // registration the server is guaranteed never to have received was the + // one reported as registered, and only a log line said otherwise. + // + // Driven through the store rather than by minting 513 invites: the cap + // is InviteStore's and this is what it does when it is reached. + Analytics.setConsent(null); + implementation.setAutoProcessConnections(false); + Invite first = Invites.create(InviteRequest.create().campaign("evicted").build()); + assertNotNull(first); + assertFalse(Invites.isRegistered(first), + "the fixture is already acknowledged, so the assertion below proves nothing"); + + List stuffed = new ArrayList(InviteStore.readOutbox()); + while (stuffed.size() <= InviteStore.MAX_OUTBOX) { + stuffed.add("{\"code\":\"FILLER" + stuffed.size() + "\"}"); + } + assertTrue(InviteStore.writeOutbox(stuffed), "the stuffed outbox could not be written"); + + assertFalse(Invites.isRegistered(first), + "an evicted registration reported itself as acknowledged"); + } + @FormTest void aFailedPendingWriteDoesNotLoseTheDirectCode() { // handleUrl() commits STATE_PENDING and issues the claim before it @@ -1483,6 +1510,53 @@ public void attributionUnavailable(String reason) { "the state was committed without its marker"); } + @Test + @EdtTest + void afailedDeliveryWriteIsStillOwedToTheListener() { + // The other half of the record-held-in-memory change. When the + // delivered=true write fails, the callback is withheld -- but the map + // carrying that flag is the one the failure holds for retry, so the + // next read persisted the very flag the failure was supposed to + // prevent. The answer then read as already delivered and the listener + // never heard it, on this launch or any other. + Invites.setAttributionWindow(0); + Invites.checkForInvite(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "the fixture did not reach a terminal answer"); + + final int[] told = new int[1]; + InviteStore.failNextWriteForTest(InviteStore.PENDING); + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + assertEquals(0, told[0], + "a delivery the device could not record was reported anyway"); + + // The record must not claim it was delivered, or nothing will ever + // report it. + Map marker = Invites.pendingRecordForTest(); + assertNotNull(marker); + assertFalse(InviteStore.getBoolean(marker, "delivered", false), + "a delivery that never happened was recorded as done"); + + // And a listener registered afterwards is told, which is the contract: + // exactly one callback per install, and the answer is remembered. + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + assertEquals(1, told[0], "the answer was owed to the listener and never arrived"); + } + @Test @EdtTest void aFirstTimeDenialStartsItsOwnClock() { From 9fe42d087394c8433a93502a59d0ff1a68b3c2b3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:57:19 +0300 Subject: [PATCH 33/70] Invites: reconcile the cached state when a held record finally lands markTerminal() deliberately does not set the in-memory state when its write fails, so the record held for retry can be terminal while memory still says pending. Persisting it later without saying so left the two disagreeing: a flush read the cached pending state, treated the lookup as live, rewrote the terminal marker back to STATE_PENDING and issued another lookup -- with the device profile markTerminal had stripped, so it could not have matched anyway. readPending() invalidates the cached state when a held record reaches the disk. Invalidating rather than assigning, because what the record means depends on the re-attribution setting and on whether an attribution exists, and loadState() is the one place that knows. The cost is one extra read of a record just written, and only after a storage failure. Checked by reverting the invalidation: the state stays PENDING while the record on the disk says otherwise. --- .../codename1/analytics/invite/Invites.java | 20 +++++++++++- .../invite/InviteResilienceTest.java | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index c8bc826877b..e610d2bc16e 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1481,7 +1481,25 @@ static Map pendingRecordForTest() { private static Map readPending() { Map held = pendingFallback; if (held != null) { - writePending(held); + if (writePending(held)) { + // The cached state is invalidated, not left as it was. + // + // markTerminal() deliberately does NOT set the state when its + // write fails, so the record held here can be terminal while + // memory still says pending. Persisting it without saying so + // left the two disagreeing: a later flush read the cached + // pending state, treated the lookup as live, rewrote the + // terminal marker back to STATE_PENDING and issued another + // lookup -- with the device profile markTerminal had stripped, + // so it could not have matched anyway. + // + // Invalidating rather than assigning, because what the record + // means depends on the re-attribution setting and on whether an + // attribution exists, and loadState() is the one place that + // knows. The cost is one extra read of a record just written, + // and only after a storage failure. + stateLoaded = false; + } return held; } return InviteStore.read(InviteStore.PENDING); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 6ab80b9254b..14daf4d23d6 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1557,6 +1557,37 @@ public void attributionUnavailable(String reason) { assertEquals(1, told[0], "the answer was owed to the listener and never arrived"); } + @Test + @EdtTest + void aterminalMarkerPersistedLateIsNotReopenedAsPending() { + // markTerminal() deliberately does not set the state when its write + // fails, so the record held for retry can be terminal while memory + // still says pending. Persisting it without reconciling left the two + // disagreeing: a later flush read the cached pending state, treated the + // lookup as live, rewrote the terminal marker back to STATE_PENDING and + // issued another lookup -- with the device profile markTerminal had + // stripped, so it could not have matched anyway. + Invites.checkForInvite(); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + // The terminal write fails, so the answer is held rather than recorded. + Invites.setAttributionWindow(0); + InviteStore.failNextWriteForTest(InviteStore.PENDING); + Invites.forgetLoadedState(); + Invites.checkForInvite(); + + // Storage recovers: the next read persists the held terminal record. + Map persisted = Invites.pendingRecordForTest(); + assertNotNull(persisted); + assertEquals(Invites.STATE_NONE_FOUND, + InviteStore.getInt(InviteStore.read(InviteStore.PENDING), "state", -1), + "the held terminal record was never persisted"); + + // And the state agrees with the record that is now on the disk. + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "the cached state still says pending, so a flush will reopen a settled lookup"); + } + @Test @EdtTest void aFirstTimeDenialStartsItsOwnClock() { From 3c16d236443c9e06c49a2c93dfc930179564e566 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:25:03 +0300 Subject: [PATCH 34/70] Invites: getState() never answers from a cache the record contradicts markTerminal() deliberately does not set the state when its write fails, so the record held for retry can be terminal while memory still says pending. loadState() now reconciles the held copy before it trusts the cached answer, which puts it ahead of every state decision because they all come through that method. The review round that prompted this predicted more than measurement supports, and the code says so where the guard is. It claimed flush() would act on the stale answer, reopen the marker and issue a fresh lookup without the profile markTerminal strips. Traced end to end with the reconciliation removed: it does not. Every path that reopens or rewrites the record reads it first, and readPending() drains the held copy and invalidates the cache before anything is written -- flush() enters its restart branch on the stale PENDING and still finishes with the state and the marker both terminal. What is real is narrower and worth fixing on its own: getState() is public API, and answering PENDING out of a cache the device's own record already contradicts is wrong whatever the caller does next. The first test written for this passed WITHOUT the fix, which is what sent me to instrument the path rather than believe it; the test now asserts the one thing that can observe the disagreement, and fails without the guard. Both places drain, and both have to: whichever reaches the held record first is the one that has to invalidate, or the other finds nothing left and trusts an answer the record has already contradicted. --- .../codename1/analytics/invite/Invites.java | 49 ++++++++++++----- .../invite/InviteResilienceTest.java | 55 ++++++++++++++++++- 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index e610d2bc16e..a1d4d01ce2d 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -698,6 +698,29 @@ public static int getState() { // and re-deriving it from storage on every call would read the disk for // every uninvited install. No locking -- the facade runs on the EDT. private static void loadState() { + // The held record is reconciled BEFORE the cached answer is trusted. + // + // markTerminal() deliberately does not set the state when its write + // fails, so the record held for retry can be terminal while memory + // still says pending -- and getState() is public API. Answering PENDING + // out of a cache the device's own record already contradicts is wrong + // on its own terms, whatever the caller then does with it. + // + // It is NOT, measured, what a review round claimed: that flush() would + // act on the stale answer, reopen the marker and issue a fresh lookup + // without the profile markTerminal strips. It does not, because every + // path that reopens or rewrites the record reads it first, and that + // read drains the held copy and invalidates the cache before anything + // is written. Traced end to end with the drain here removed: flush() + // enters its restart branch on the stale PENDING and still finishes + // with the state and the marker both terminal. + // + // Kept anyway, because "the answer is only ever wrong to callers that + // go on to correct it" is an invariant nobody can see from here. + Map held = pendingFallback; + if (held != null && writePending(held)) { + stateLoaded = false; + } if (stateLoaded) { return; } @@ -1474,6 +1497,10 @@ private static void forgetPendingFallback() { /// #### Returns /// /// the record, or null + static boolean pendingFallbackPresentForTest() { + return pendingFallback != null; + } + static Map pendingRecordForTest() { return readPending(); } @@ -1482,22 +1509,14 @@ private static Map readPending() { Map held = pendingFallback; if (held != null) { if (writePending(held)) { - // The cached state is invalidated, not left as it was. - // - // markTerminal() deliberately does NOT set the state when its - // write fails, so the record held here can be terminal while - // memory still says pending. Persisting it without saying so - // left the two disagreeing: a later flush read the cached - // pending state, treated the lookup as live, rewrote the - // terminal marker back to STATE_PENDING and issued another - // lookup -- with the device profile markTerminal had stripped, - // so it could not have matched anyway. + // Invalidated here TOO, not only in loadState(). // - // Invalidating rather than assigning, because what the record - // means depends on the re-attribution setting and on whether an - // attribution exists, and loadState() is the one place that - // knows. The cost is one extra read of a record just written, - // and only after a storage failure. + // Whichever of the two drains the held record first is the one + // that has to say so. loadState() reconciles before any state + // decision is made, which is what beginDeferred() needs; but a + // caller that reads the record directly can get here first, and + // then loadState() finds nothing left to drain and trusts a + // cached answer the record has already contradicted. stateLoaded = false; } return held; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 14daf4d23d6..1ecf0dd03b2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1506,8 +1506,22 @@ public void attributionUnavailable(String reason) { assertEquals(0, told[0], "an answer the device cannot remember was reported to the listener"); - assertTrue(Invites.getState() != Invites.STATE_NONE_FOUND, - "the state was committed without its marker"); + // The disk carries no terminal marker, which is the condition this + // guards: the state must not be terminal while the only copy of that + // answer is in memory. Read through InviteStore rather than through + // Invites, because the accessor now reconciles the held copy first -- + // which is the point of the assertion below. + assertNotEquals(Invites.STATE_NONE_FOUND, + InviteStore.getInt(InviteStore.read(InviteStore.PENDING), "state", + Invites.STATE_PENDING), + "the terminal marker reached the disk, so this proves nothing"); + + // And the answer is deferred rather than dropped: the held record is + // persisted by the next read and the state then agrees with it. Before + // the record was held at all this stayed pending for ever, so the same + // lookup ran again on every launch and the listener heard nothing. + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "the terminal answer was neither recorded nor reachable afterwards"); } @Test @@ -1588,6 +1602,43 @@ void aterminalMarkerPersistedLateIsNotReopenedAsPending() { "the cached state still says pending, so a flush will reopen a settled lookup"); } + @Test + @EdtTest + void getStateNeverAnswersFromAcacheTheRecordContradicts() { + // markTerminal() deliberately does not set the state when its write + // fails, so the record held for retry can be terminal while memory + // still says pending. Every path that ACTS on the state reads the + // record and reconciles on the way, so the disagreement never reached a + // write -- but getState() is public API, and answering PENDING out of a + // cache the device's own record already contradicts is wrong on its own + // terms. Reconciled at the top of loadState(), which every state + // decision comes through. + Invites.checkForInvite(); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + Invites.setAttributionWindow(0); + InviteStore.failNextWriteForTest(InviteStore.PENDING); + Invites.forgetLoadedState(); + Invites.checkForInvite(); + + // Nothing has read the record yet, so the terminal answer is still only + // in memory and the cached state still says pending -- the precondition + // this is about. + assertTrue(Invites.pendingFallbackPresentForTest(), + "the record was already persisted, so this proves nothing"); + + // getState() is public API and must not answer out of a cache the + // device's own record contradicts. Asserted before anything else + // touches the record, because every path that acts on the state reads + // the record and reconciles on the way -- so this is the one caller + // that can observe the disagreement. + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "getState() answered from a cache the held record contradicts"); + assertEquals(Invites.STATE_NONE_FOUND, + InviteStore.getInt(InviteStore.read(InviteStore.PENDING), "state", -1), + "asking for the state did not persist the record it answered from"); + } + @Test @EdtTest void aFirstTimeDenialStartsItsOwnClock() { From 2afe6d2c96eaea7ee8976adb120498ebd22f17ca Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:02:45 +0300 Subject: [PATCH 35/70] Invites: four review findings, one of them a design decision reversed The standard launch mode no longer fails the build. The guard refused it outright, on the reasoning that a link then starts a second activity and the invite is lost. A review round pointed at the generated stub's `private Form currentForm` -- an INSTANCE field -- and it is right: the second activity's copy is null, so wasStopped is true, the generated run() reaches createStartInvocation(), and the application's start() reads the link out of getAppArg() exactly as on a cold launch. The invite arrives. Refusing rejected a configuration apps already build and ship with, so it warns instead and names the colder path. Queued registrations went out carrying the consent they were minted under. Under the default opt-in mode an invite is usually minted BEFORE the prompt is answered, so the serialized body says consentAnalytics:false; draining is gated on consent, but the flag travels with the body and the analytics transport reads it as the proof that the gate was satisfied. Rewritten at drain time -- rewritten, not rebuilt, because the campaign, payload and preview are what the invite was minted with and must not be re-derived from today's state. dispatchNewIntentUrl mutated the caller's Intent. It runs from CodenameOneActivity.onNewIntent, and the ordinary way to extend that is super.onNewIntent(intent) followed by reading intent.getData() -- which had just been set to null underneath the override, so custom deep-link routing that worked before lost the url. The consumption happens on a copy, stored with setIntent; the object the override holds is left as the OS handed it over. setReattribution(false) did not stop a replacement already on the wire. The response still passed handleResolution()'s epoch guard and overwrote the first-touch attribution the setting had just said to keep. The epoch bump fails it on arrival. The first attempt at this deleted the durable replacement record too -- turningOnReattributionLetsTheStateBeReadAgain caught that, and it is right: the off/on round trip is supported and the record is a link the user really did open. What is cancelled is the request, not the invite. Each has a test checked by reverting its fix. --- .../codename1/analytics/invite/Invites.java | 71 ++++++++++++++++++- .../impl/android/AndroidImplementation.java | 25 +++++-- .../builders/AndroidGradleBuilder.java | 38 ++++++---- .../builders/AndroidInviteNewIntentTest.java | 47 ++++++++++++ .../invite/InviteResilienceTest.java | 65 +++++++++++++++++ 5 files changed, 224 insertions(+), 22 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index a1d4d01ce2d..8a8f09b16b3 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -868,6 +868,7 @@ public static long getAttributionWindow() { /// /// - `value`: true for last touch public static void setReattribution(boolean value) { + boolean wasOn = reattribution; reattribution = value; // The cached state was derived under the old value. loadState() reads // the pending record only when re-attribution is on, so a process that @@ -875,6 +876,31 @@ public static void setReattribution(boolean value) { // replacement again -- and setInviteListener(), which most applications // call first, is enough to cache it. stateLoaded = false; + // Turning it OFF discards a replacement RESPONSE already in flight. + // + // Changing the setting alone only changed how the state is read: an + // outstanding replacement still passed handleResolution()'s epoch guard + // and overwrote the first-touch attribution the setting had just said + // to keep. The epoch bump fails it on arrival. + // + // The durable replacement record deliberately stays. Turning the + // setting off and on again is a supported round trip -- there is a test + // named for it -- and deleting the record would lose a link the user + // really did open. What is cancelled is the request, not the invite. + // + // Guarded on there BEING an attribution, because that is what makes an + // outstanding lookup a replacement. On a device with no attribution yet + // the lookup in flight is the first one, and turning last touch off + // says nothing about it -- discarding it there would lose an ordinary + // install's attribution outright. + if (wasOn && !value && getAttribution() != null) { + lookupEpoch++; + // Nothing is outstanding once the epoch has moved, so a later + // resume can issue its own request rather than waiting out a retry + // delay for one that can no longer be acted on. + lookupIssuedAt = 0; + deferredStarted = false; + } } /// Whether last touch attribution is enabled. @@ -2482,7 +2508,50 @@ private static void drainOutbox() { // Re-posting an entry that did land is harmless: the server keys on // the code and treats a repeat from the same inviter as idempotent. for (String json : outbox) { - postRegistration(json); + postRegistration(withCurrentConsent(json)); + } + } + + /// Rewrites a queued registration's consent flag to what consent says now. + /// + /// The body is serialized at mint time, and under the default opt-in mode + /// an invite is very often minted BEFORE the prompt is answered -- so the + /// stored JSON carries `consentAnalytics:false`. Draining is already gated + /// on consent having been granted, but the field travels with the body and + /// the analytics transport reads it as the proof that the gate was + /// satisfied. Sent unchanged, a registration queued before the grant + /// arrived looking unconsented and could be refused, and the link it + /// describes would keep its code and lose its campaign, payload and + /// preview for good. + /// + /// Rewritten rather than rebuilt: everything else in the entry -- the code + /// and the metadata -- is what the invite was minted with and must not be + /// re-derived from today's state. + /// + /// - `json`: the queued registration + /// + /// #### Returns + /// + /// the registration with a current consent flag, or the original when it + /// cannot be parsed + private static String withCurrentConsent(String json) { + if (json == null) { + return null; + } + try { + Map body = + new JSONParser().parseJSON(new java.io.StringReader(json)); + if (body == null) { + return json; + } + body.put("consentAnalytics", Boolean.valueOf(allowed())); + return JSONParser.mapToJson(body); + } catch (Throwable t) { + // An entry that cannot be parsed is still worth sending as it is: + // the alternative is dropping a registration whose metadata exists + // nowhere else. + Log.e(t); + return json; } } diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 3552619336f..788d9c8aeff 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1697,13 +1697,24 @@ static void dispatchNewIntentUrl(Intent intent) { // rather than whatever the previous intent left cached. instance.setAppArg(null); clearIntentProperties(); - // And the intent's data is consumed, exactly as the lazy - // getAppArg() path consumes it. CodenameOneActivity.onStop() clears - // the app arg, so leaving the data on the intent meant the next - // read after a resume rebuilt the same url from it, and an - // application that handles AppArg in start() saw the deep link a - // second time -- opening the same invite twice for one tap. - intent.setData(null); + // The data is consumed on a COPY, never on the caller's intent. + // + // getAppArg() rebuilds the url from the activity's stored intent, and + // CodenameOneActivity.onStop() clears the app arg -- so leaving the data + // in place meant the next read after a resume rebuilt the same url and + // an application that handles AppArg in start() saw the deep link a + // second time, opening the same invite twice for one tap. + // + // Clearing it on the intent passed in was worse. This runs from + // CodenameOneActivity.onNewIntent(), and the ordinary way to extend that + // is super.onNewIntent(intent) followed by the subclass reading + // intent.getData() -- which had just been set to null underneath it, so + // custom deep-link routing that worked before lost the url entirely. The + // copy is what the activity stores; the object the override holds is + // left exactly as the OS handed it over. + android.content.Intent consumed = new android.content.Intent(intent); + consumed.setData(null); + getActivity().setIntent(consumed); Display.getInstance().setProperty("AppArg", data.toString()); } catch (Throwable t) { com.codename1.io.Log.e(t); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 6baa2a3fd64..6877aea9e95 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -2979,22 +2979,32 @@ public void usesClassMethod(String cls, String method) { debug("Invite attribution: adding the App Links filter for " + inviteHost); request.putArgument("android.xintent_filter", withAppLinks); } - // launchMode decides whether a link reaching an app that is - // already running is delivered to it at all. singleTop (the - // default) and singleTask both route through onNewIntent; - // "standard" starts a SECOND activity and a second lifecycle, and - // the invite is simply lost. Refused rather than warned: a warning - // in a build log is exactly the thing nobody reads, and the - // symptom on the device is a feature that silently never fires. + // launchMode decides WHICH delivery path a link takes, not whether + // it arrives. + // + // singleTop (the default) and singleTask route a link into the + // running activity through onNewIntent. "standard" starts a SECOND + // activity instead -- and that activity's `currentForm` is an + // INSTANCE field, so it is null, `wasStopped` is true, and the + // generated run() goes on to createStartInvocation(): the + // application's start() runs and reads the link out of getAppArg() + // exactly as it does on a cold launch. + // + // This refused the build outright until a review round pointed at + // that field. It was wrong: the invite is delivered, and refusing + // rejected a configuration the app already built and shipped with. + // Warned instead, because the delivery is real but the path is the + // colder one and the second activity is a surprise worth naming. String launchMode = request.getArg("android.activity.launchMode", "singleTop"); if ("standard".equals(launchMode)) { - throw new BuildException("This app uses invite attribution " - + "(com.codename1.analytics.invite), which needs an invite link to reach " - + "the running activity, but android.activity.launchMode is \"standard\". " - + "A link then starts a second activity instead of being delivered to the " - + "running one, and the invite is lost. Use singleTop (the default) or " - + "singleTask, or set android.invite.appLinks=false and handle the link " - + "yourself."); + warn("This app uses invite attribution " + + "(com.codename1.analytics.invite) with " + + "android.activity.launchMode=\"standard\". An invite link then starts a " + + "second activity rather than reaching the running one, so the invite " + + "arrives through the application's start() instead of onNewIntent(). " + + "That works, and it is what a cold launch does anyway -- but " + + "checkForInvite() has to be called from start(), and singleTop (the " + + "default) or singleTask avoids the second activity entirely."); } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java index 43d0db75ecf..b4e1633288c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java @@ -59,6 +59,10 @@ public class AndroidInviteNewIntentTest { private static final String BUILDER = "src/main/java/com/codename1/builders/AndroidGradleBuilder.java"; + /** The port source, relative to the plugin module the tests run in. */ + private static final String ANDROID_PORT = + "../../Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java"; + private String source() throws IOException { File builder = new File(BUILDER); assertTrue(builder.isFile(), "the builder must be readable: " + builder.getAbsolutePath()); @@ -92,6 +96,49 @@ void theOverrideRunsOnTheEventDispatchThread() throws IOException { "the generated onNewIntent can run before Display exists"); } + @Test + void theConsumedUrlIsClearedOnAcopyNotOnTheCallersIntent() throws IOException { + // dispatchNewIntentUrl runs from CodenameOneActivity.onNewIntent, and + // the ordinary way to extend that is super.onNewIntent(intent) followed + // by the subclass reading intent.getData(). Clearing the data on THAT + // object set it to null underneath the override, so custom deep-link + // routing that worked before lost the url entirely. + File port = new File(ANDROID_PORT); + assertTrue(port.isFile(), "the port must be readable: " + port.getAbsolutePath()); + String source = new String(Files.readAllBytes(port.toPath()), StandardCharsets.UTF_8); + int at = source.indexOf("static void dispatchNewIntentUrl("); + assertTrue(at > 0, "dispatchNewIntentUrl is gone"); + String block = source.substring(at, source.indexOf("\n }", at)); + assertTrue(!block.contains("intent.setData(null)"), + "the caller's intent is mutated, so a subclass reading it after " + + "super.onNewIntent() finds no data"); + assertTrue(block.contains("new android.content.Intent(intent)") + && block.contains("consumed.setData(null)"), + "the url is no longer consumed on a copy"); + } + + @Test + void standardLaunchModeIsWarnedAboutRatherThanRefused() throws IOException { + // It refused the build outright until a review round pointed at the + // generated stub's `private Form currentForm` -- an INSTANCE field. A + // standard-mode App Link starts a SECOND activity, whose copy of that + // field is null, so wasStopped is true and the generated run() reaches + // createStartInvocation(): the application's start() runs and reads the + // link out of getAppArg() exactly as on a cold launch. The invite is + // delivered, and refusing rejected a configuration the app already + // built and shipped with. + String source = source(); + int guard = source.indexOf("\"standard\".equals(launchMode)"); + assertTrue(guard > 0, "the launch-mode guard is gone"); + int end = source.indexOf("\n }", guard); + assertTrue(end > guard, "the guard block moved"); + String block = source.substring(guard, end); + assertTrue(block.contains("warn("), + "a working launch mode is refused instead of warned about"); + assertTrue(!block.contains("throw new BuildException"), + "standard launch mode still fails the build"); + } + @Test void theInviteReferenceOnlyExistsForAppsThatUseInvites() throws IOException { String source = source(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 1ecf0dd03b2..64d1a456390 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -564,6 +564,44 @@ void anEvictedRegistrationIsNotReportedAsRegistered() { "an evicted registration reported itself as acknowledged"); } + @FormTest + void aqueuedRegistrationIsSentWithTodaysConsentNotYesterdays() { + // The body is serialized at mint time, and under the default opt-in + // mode an invite is very often minted BEFORE the prompt is answered -- + // so the stored JSON carries consentAnalytics:false. Draining is gated + // on consent having been granted, but the flag travels WITH the body + // and the analytics transport reads it as the proof that the gate was + // satisfied. Sent unchanged, a registration queued before the grant + // arrived looking unconsented and could be refused, and the link it + // describes would keep its code and lose its campaign, payload and + // preview for good. + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create().campaign("launch").build()); + assertNotNull(invite); + assertEquals(0, implementation.getQueuedRequests().size(), + "the registration was transmitted before consent was given"); + + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.flush(); + + String body = null; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + String candidate = implementation.getQueuedRequests().get(i).getRequestBody(); + if (candidate != null && candidate.indexOf(invite.getCode()) >= 0) { + body = candidate; + } + } + assertNotNull(body, "the queued registration was never drained"); + assertTrue(body.indexOf("\"consentAnalytics\":true") >= 0 + || body.indexOf("\"consentAnalytics\": true") >= 0, + "the registration went out with the consent it was minted under: " + body); + assertTrue(body.indexOf("launch") >= 0, + "rewriting the consent flag lost the metadata the outbox exists to keep"); + } + @FormTest void aFailedPendingWriteDoesNotLoseTheDirectCode() { // handleUrl() commits STATE_PENDING and issues the claim before it @@ -1218,6 +1256,33 @@ void turningOnReattributionLetsTheStateBeReadAgain() { "the cached state hid the durable replacement"); } + @Test + @EdtTest + void turningReattributionOffDiscardsAreplacementAlreadyInFlight() { + // Changing the setting only changed how the state is READ. An + // outstanding replacement response still passed handleResolution()'s + // epoch guard and overwrote the first-touch attribution the setting had + // just said to keep -- so an application that turned last touch off + // could still have a user's cohort change underneath its reports, once, + // by a request that was already on the wire. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST8", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND8"); + int inFlight = Invites.currentLookupEpochForTest(); + + Invites.setReattribution(false); + + // The response that was already on the wire lands now. + Invites.handleResolution(InviteTestSupport.resolvedJson("SECOND8", "c1", "sms"), + Invites.MATCH_DIRECT, false, inFlight); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a); + assertEquals("FIRST8", a.getCode(), + "an in-flight replacement overwrote first touch after last touch was turned off"); + } + @Test @EdtTest void aResumedLookupThatEndsTerminallyIsNotAnnouncedTwice() { From 3b1d4c0727a4f8d2a02bb15da5d248ba595b0d9a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:24:32 +0300 Subject: [PATCH 36/70] Invites: an erasure that lasts, a kill switch that reaches the wire reset() left the state at STATE_NONE, which is indistinguishable from a fresh install -- so the next ordinary checkForInvite() built a new device profile and started deferred matching again. Inside the original click window, which on iOS is the normal path, the server can match the same device to the same click and restore the very inviter dimensions the user asked to be rid of, under their new client id. The erasure lasted until the next launch. eraseInternal() leaves a tombstone: a state and a reason and nothing else -- no code, no fingerprint, no identifier, none of what the erasure removed. It is marked delivered, because the answer it stands for was already given and has just been erased, and its reason is not one beginDeferred() reopens. A direct link still reopens attribution, since handleUrl() overwrites the state and clears the reason. That asymmetry is the point: somebody who erases their identity and then taps a new invite is asking for that invite; somebody who erases it and reopens the app is not. setAttributionWindow(0) changed only what future calls read. A statistical request queued a moment earlier carries the epoch it was issued with, so its answer still landed, persisted and reported an attribution the application had just switched off. The window is read against the ANSWER now -- and only the deferred one, because the switch turns off the statistical lookup and not an exact code the device is holding. hasSavedCode() exempts one where the lookup begins and this keeps the same exemption from the other end, which is also why it is not an epoch bump: the epoch is global and would discard the direct claim with it. And the consent rewrite from the previous commit broke acknowledgement. It passed the rewritten JSON as both the body and the outbox key, so outbox.remove() matched nothing: every registration would be resent on every flush for ever and isRegistered() would never become true. The body is rewritten and the original stays the key. The test that covered the rewrite only asserted the body it sent, which is how it got through. Each fix has a test checked by reverting it. --- .../codename1/analytics/invite/Invites.java | 98 ++++++++++++++++++- .../invite/InviteConsentAndErasureTest.java | 41 +++++++- .../invite/InviteResilienceTest.java | 90 +++++++++++++++++ 3 files changed, 225 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 8a8f09b16b3..0729ba03745 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -148,6 +148,13 @@ public final class Invites { /// This platform cannot recover a deferred invite. public static final String REASON_UNSUPPORTED = "unsupported"; + // Not public, and never delivered to a listener. It is the marker an + // erasure leaves behind so the automatic lookup does not start again, and + // an application has no decision to make about it -- the reasons above are + // answers about an invite, this is a record that there is no longer anyone + // to answer about. + static final String REASON_ERASED = "erased"; + /// The analytics category every invite event is reported under. public static final String CATEGORY = "referral"; @@ -625,6 +632,11 @@ public static boolean handleUrl(String url) { // are holding the code for, so a referrer read is no longer a better // answer waiting to happen. pending.remove("referrerRetry"); + // And any terminal reason the record was carrying, which is what makes + // a direct link the one thing that reopens an erased install: the + // tombstone eraseInternal() leaves is a state and a reason, and this + // overwrites both rather than reopening around them. + pending.remove("reason"); writePending(pending); setState(STATE_PENDING); // A deferred fingerprint or referrer lookup may already be on the wire, @@ -992,6 +1004,36 @@ static void forgetCachedAttributionForTest() { // id changes underneath us, which is what an erasure request looks like. static void eraseInternal() { reset(); + // A tombstone, so the erasure is not undone by the next ordinary + // launch. + // + // reset() deletes the records and leaves the state at STATE_NONE, which + // is indistinguishable from a fresh install -- so the next routine + // checkForInvite() built a new profile and started deferred matching + // again. Inside the original click window, which on iOS is the normal + // path, the server can match the same device to the same click and + // restore the very inviter dimensions the user asked to be rid of, + // under their new client id. The erasure would have lasted until the + // next launch. + // + // The marker carries a state and a reason and NOTHING else: no code, no + // fingerprint, no identifier, nothing the erasure was meant to remove. + // It is marked delivered because there is no answer owed to anyone -- + // the install had one and it has just been erased -- and its reason is + // not one beginDeferred() reopens, so the automatic lookup stays off. + // + // A direct link still reopens attribution: handleUrl() overwrites the + // state and clears the reason, which is the right asymmetry. Somebody + // who erases their identity and then taps a new invite is asking for + // that invite; somebody who erases it and reopens the app is not. + Map erased = new LinkedHashMap(); + erased.put("state", String.valueOf(STATE_NONE_FOUND)); + erased.put("reason", REASON_ERASED); + erased.put("delivered", "true"); + if (writePending(erased)) { + state = STATE_NONE_FOUND; + stateLoaded = true; + } } // Package private: called from the provider when consent changes. @@ -1962,9 +2004,30 @@ private static void post(String url, Map body, String matchType, private static void send(String url, String json, String matchType, boolean deferred, boolean registration) { + send(url, json, json, matchType, deferred, registration); + } + + /// Sends `json`, and remembers `outboxKey` as the entry to retire when the + /// server accepts it. + /// + /// The two are the same string everywhere except one place: a queued + /// registration is rewritten on the way out so its consent flag is current, + /// and the entry sitting in the outbox is still the original. Passing the + /// rewritten body as the key made `outbox.remove(...)` match nothing, so + /// the registration was resent on every flush for ever and isRegistered() + /// never became true. + /// + /// - `url`: where to send it + /// - `json`: the body to transmit + /// - `outboxKey`: the stored entry this acknowledges, or null + /// - `matchType`: how the attribution was reached + /// - `deferred`: whether this is the statistical path + /// - `registration`: whether this is a mint registration + private static void send(String url, String json, String outboxKey, String matchType, + boolean deferred, boolean registration) { try { InviteConnection req = new InviteConnection(matchType, deferred, registration, - registration ? json : null, lookupEpoch); + registration ? outboxKey : null, lookupEpoch); req.setUrl(url); req.setPost(true); req.setContentType("application/json"); @@ -2086,6 +2149,24 @@ static void handleResolution(String payload, String matchType, boolean deferred, if (epoch != lookupEpoch || !allowed()) { return; } + // And the kill switch is read HERE, not only where the lookup starts. + // + // setAttributionWindow(0) turns off deferred attribution, but a + // statistical request queued a moment earlier is already on the wire + // and carries the epoch it was issued with -- so its answer used to + // land, persist and report an attribution the application had just + // switched off. The window is checked against the answer rather than + // against the request. + // + // Only the DEFERRED answer. The switch turns off the statistical + // lookup, not an exact code the device is holding: hasSavedCode() + // exempts one where the lookup begins, and cancelling a direct claim + // here would break the same exemption from the other end. That is also + // why this is not an epoch bump -- the epoch is global and would + // discard the direct claim with it. + if (deferred && attributionWindow == 0) { + return; + } try { if (payload == null || payload.length() == 0) { return; @@ -2508,7 +2589,10 @@ private static void drainOutbox() { // Re-posting an entry that did land is harmless: the server keys on // the code and treats a repeat from the same inviter as idempotent. for (String json : outbox) { - postRegistration(withCurrentConsent(json)); + // The body is rewritten, the KEY is not. The outbox still holds the + // original string, and that is what has to be removed when the + // server accepts it. + postRegistration(withCurrentConsent(json), json); } } @@ -2556,7 +2640,15 @@ private static String withCurrentConsent(String json) { } private static void postRegistration(String json) { - send(getLinkBase() + PATH_MINT, json, MATCH_DIRECT, false, true); + postRegistration(json, json); + } + + /// Posts `body`, retiring `outboxKey` from the outbox when it lands. + /// + /// - `body`: the registration to transmit + /// - `outboxKey`: the stored entry it stands for + private static void postRegistration(String body, String outboxKey) { + send(getLinkBase() + PATH_MINT, body, outboxKey, MATCH_DIRECT, false, true); } // Called from the registration response, once its status has been checked. diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index d42af34ae9c..c68e1fe7f27 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -114,7 +114,46 @@ void resetClientIdErasesTheReferralDimensionsAndKeepsTheApplicationsOwn() { // destroying data it never asked to lose. assertEquals("pro", dims.get("plan")); assertNull(Invites.getAttribution()); - assertEquals(Invites.STATE_NONE, Invites.getState()); + // Terminal, not STATE_NONE. STATE_NONE is indistinguishable from a + // fresh install, and that is precisely what let the erasure be undone: + // the next ordinary checkForInvite() built a new profile and started + // deferred matching again, and inside the original click window the + // server can match the same device to the same click and restore the + // same inviter under the new client id. The tombstone carries a state + // and a reason and nothing else. + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + } + + @FormTest + void anerasedInstallDoesNotStartLookingAgainByItself() { + // The erasure has to survive the next launch, not just the moment it + // happens. Nothing personal is kept to achieve it -- the marker is a + // state and a reason -- but the automatic lookup must not restart, or + // the server can hand the same inviter back under the new identity. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution()); + + Analytics.resetClientId(); + implementation.clearQueuedRequests(); + + // The next ordinary launch. + Invites.forgetLoadedState(); + Invites.checkForInvite(); + + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "an erased install started deferred matching again by itself"); + assertEquals(0, implementation.getQueuedRequests().size(), + "an erased install sent a fresh device profile to the server"); + + // And a NEW invite still reopens it: erasing an identity is not a + // decision about an invite the person taps afterwards. + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/AFTER1")); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a direct invite could not reopen attribution after an erasure"); } @FormTest diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 64d1a456390..0b6ff116f08 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -602,6 +602,55 @@ void aqueuedRegistrationIsSentWithTodaysConsentNotYesterdays() { "rewriting the consent flag lost the metadata the outbox exists to keep"); } + @FormTest + void arewrittenRegistrationStillRetiresItsOriginalOutboxEntry() { + // The body is rewritten on the way out so its consent flag is current; + // the entry sitting in the outbox is still the original. Passing the + // rewritten string as the acknowledgement key made outbox.remove() + // match nothing, so the registration was resent on every flush for ever + // and isRegistered() never became true -- a fix for one silent failure + // that introduced a louder one. + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create().campaign("launch").build()); + assertNotNull(invite); + List queued = InviteStore.readOutbox(); + assertEquals(1, queued.size(), "the registration was not queued"); + String stored = queued.get(0); + assertTrue(stored.indexOf("false") >= 0, + "the fixture was queued with consent already granted"); + + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.flush(); + + // The server accepts it. The connection has to hand back the ORIGINAL + // entry, or nothing is retired. + Invites.InviteConnection req = null; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + ConnectionRequest r = implementation.getQueuedRequests().get(i); + if (r instanceof Invites.InviteConnection + && r.getRequestBody() != null + && r.getRequestBody().indexOf(invite.getCode()) >= 0) { + req = (Invites.InviteConnection) r; + } + } + assertNotNull(req, "the queued registration was never sent"); + try { + req.readResponse(new ByteArrayInputStream( + "{\"registered\":true}".getBytes("UTF-8"))); + } catch (IOException e) { + throw new IllegalStateException(e); + } + req.postResponse(); + + assertEquals(0, InviteStore.readOutbox().size(), + "the acknowledged registration stayed in the outbox and will be resent for ever"); + assertTrue(Invites.isRegistered(invite), + "an acknowledged registration never reports itself registered"); + } + @FormTest void aFailedPendingWriteDoesNotLoseTheDirectCode() { // handleUrl() commits STATE_PENDING and issues the claim before it @@ -1232,6 +1281,47 @@ public void attributionUnavailable(String reason) { assertEquals(Invites.STATE_PENDING, Invites.getState()); } + @Test + @EdtTest + void thekillSwitchAlsoRefusesAmatchAlreadyOnTheWire() { + // setAttributionWindow(0) changed only the value future calls read. A + // statistical request queued a moment earlier carries the epoch it was + // issued with, so its answer still landed, persisted and reported an + // attribution the application had just switched off. + Invites.checkForInvite(); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + int inFlight = Invites.currentLookupEpochForTest(); + + Invites.setAttributionWindow(0); + + Invites.handleResolution(InviteTestSupport.resolvedJson("LATE1", "c1", "sms"), + Invites.MATCH_FINGERPRINT, true, inFlight); + + assertNull(Invites.getAttribution(), + "a statistical answer landed after the kill switch was thrown"); + } + + @Test + @EdtTest + void thekillSwitchStillLetsAnExactAnswerLand() { + // The switch turns off the STATISTICAL lookup, not an exact code the + // device is holding -- hasSavedCode() exempts one where the lookup + // begins, and refusing a direct claim on the way back in would break + // the same exemption from the other end. This is why the guard reads + // the deferred flag rather than bumping the epoch, which is global. + Invites.handleUrl("https://cloud.codenameone.com/i/acme/EXACT7"); + int inFlight = Invites.currentLookupEpochForTest(); + + Invites.setAttributionWindow(0); + + Invites.handleResolution(InviteTestSupport.resolvedJson("EXACT7", "c1", "sms"), + Invites.MATCH_DIRECT, false, inFlight); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a, "the kill switch discarded an exact answer we had asked for"); + assertEquals("EXACT7", a.getCode()); + } + @Test @EdtTest void turningOnReattributionLetsTheStateBeReadAgain() { From 0d0e067b95f648b89076b0f253aa45bdb5109c39 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:53:49 +0300 Subject: [PATCH 37/70] Invites: the kill switch is about the guess, not about being deferred The response guard keyed on `deferred`, and an install-referrer claim is exact AND deferred: the code came back through the store, which is the whole reason the Android path is the deterministic one. So setAttributionWindow(0) dropped the best answer the device will ever have -- the same saved-code exemption beginDeferred() honours when it starts a lookup, broken from the returning end. Keyed on MATCH_FINGERPRINT now, which is what the switch actually turns off. A test covers the referrer claim landing past the switch alongside the existing ones for the fingerprint answer being refused and the direct claim still landing. --- .../codename1/analytics/invite/Invites.java | 14 ++++++++---- .../invite/InviteResilienceTest.java | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 0729ba03745..1a9a689050f 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2158,13 +2158,19 @@ static void handleResolution(String payload, String matchType, boolean deferred, // switched off. The window is checked against the answer rather than // against the request. // - // Only the DEFERRED answer. The switch turns off the statistical + // Only the STATISTICAL answer. The switch turns off the fingerprint // lookup, not an exact code the device is holding: hasSavedCode() - // exempts one where the lookup begins, and cancelling a direct claim + // exempts one where the lookup begins, and cancelling an exact claim // here would break the same exemption from the other end. That is also // why this is not an epoch bump -- the epoch is global and would - // discard the direct claim with it. - if (deferred && attributionWindow == 0) { + // discard the exact claim with it. + // + // Keyed on the match type rather than on `deferred`, which was the + // first spelling and was wrong: an install-referrer claim is exact AND + // deferred -- the code came back through the store, which is the whole + // reason the Android path is the deterministic one -- so the kill + // switch dropped the best answer the device will ever have. + if (MATCH_FINGERPRINT.equals(matchType) && attributionWindow == 0) { return; } try { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 0b6ff116f08..570339f3c10 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1322,6 +1322,28 @@ void thekillSwitchStillLetsAnExactAnswerLand() { assertEquals("EXACT7", a.getCode()); } + @Test + @EdtTest + void thekillSwitchStillLetsAnInstallReferrerClaimLand() { + // An install-referrer claim is exact AND deferred: the code came back + // through the store, which is the whole reason the Android path is the + // deterministic one. Keying the guard on the deferred flag therefore + // dropped the best answer the device will ever have -- the same + // saved-code exemption the lookup start honours, broken from the + // returning end. + Invites.checkForInvite(); + int inFlight = Invites.currentLookupEpochForTest(); + + Invites.setAttributionWindow(0); + + Invites.handleResolution(InviteTestSupport.resolvedJson("REF9", "c1", "sms"), + Invites.MATCH_REFERRER, true, inFlight); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a, "the kill switch discarded an exact install-referrer claim"); + assertEquals("REF9", a.getCode()); + } + @Test @EdtTest void turningOnReattributionLetsTheStateBeReadAgain() { From 7c7585380631ade023ef283fc6554a3a87548311 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:15:48 +0300 Subject: [PATCH 38/70] Invites: an erasure that survives a failed write, and three smaller holes The erasure tombstone was written and its result ignored. If that write failed and the process exited before any read retried the held copy, no marker survived -- and InviteAttributionProvider had already recorded the new client id as its baseline, so the next launch saw no change of identity, did not erase again, and found a state indistinguishable from a fresh install: free to start deferred attribution and be handed the same inviter back under the new id. eraseInternal() reports whether it persisted and the baseline moves only when it did, which costs one repeated erasure and is the only thing here that survives the process. A completed referrer read left its in-flight timestamp behind. Under OPT_OUT with no choice on record the referrer read IS permitted, so a Play install that comes back empty falls through to the statistical match -- which needs an explicit grant and declines. onConsentChanged() then saw a lookup still in flight, did not start the match the grant had just permitted, and nothing retried it: the attribution stayed pending until some unrelated flush, check or relaunch happened along. The builder could not see invites inside a submitted library. The scan reads the application's own classes, so a cn1lib that encapsulates Invites left usesInvites false and lost the entire Android integration at once -- no App Links filter, no onNewIntent splice, the install-referrer package deleted from the generated sources, and the Play Install Referrer dependency never selected, so the library compiled against an API nothing had switched on. It rides the same LibraryClassPrefixScan the call and VPN prefixes use, and feeds the feature catalog as well as the flag. And the developer guide said setAttributionWindow(0) switches deferred attribution off entirely, which is not what it does: an exact code the device already holds is still claimed. The same sentence in code was wrong in the same direction and was fixed in the previous commit. --- .../invite/InviteAttributionProvider.java | 16 ++++++- .../codename1/analytics/invite/Invites.java | 35 ++++++++++++++-- docs/developer-guide/Analytics.asciidoc | 2 +- .../builders/AndroidGradleBuilder.java | 42 +++++++++++++++++++ .../builders/AndroidInviteNewIntentTest.java | 20 +++++++++ 5 files changed, 108 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index d553ebfeeec..47f291b1eb2 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -74,8 +74,20 @@ public void init(AnalyticsContext context) { return; } if (!last.equals(seen)) { - Invites.eraseInternal(); - Preferences.set(PREF_LAST_CLIENT_ID, seen); + // The baseline moves only once the erasure is durable. + // + // Recording the new id regardless meant a failed marker write ended + // the erasure for good: the held copy is retried by the next read of + // the record, but a process that exits before one loses it, and the + // next launch sees no change of identity, does not erase again, and + // finds a state indistinguishable from a fresh install -- free to + // start deferred attribution and be handed the same inviter back + // under the new id. Leaving the baseline where it is costs one + // repeated erasure and is the only thing here that survives the + // process. + if (Invites.eraseInternal()) { + Preferences.set(PREF_LAST_CLIENT_ID, seen); + } } } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 1a9a689050f..b23f1eef57d 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1002,7 +1002,7 @@ static void forgetCachedAttributionForTest() { // Package private: the analytics provider hook calls this when the client // id changes underneath us, which is what an erasure request looks like. - static void eraseInternal() { + static boolean eraseInternal() { reset(); // A tombstone, so the erasure is not undone by the next ordinary // launch. @@ -1030,10 +1030,25 @@ static void eraseInternal() { erased.put("state", String.valueOf(STATE_NONE_FOUND)); erased.put("reason", REASON_ERASED); erased.put("delivered", "true"); - if (writePending(erased)) { - state = STATE_NONE_FOUND; - stateLoaded = true; + if (!writePending(erased)) { + // The caller is told, because the caller is what remembers that the + // erasure happened. + // + // The held copy is retried by the next read of the record -- but if + // the process exits before one, it is gone, and the provider had + // already recorded the new client id as its baseline. The next + // launch then sees no change, does not erase again, and finds + // STATE_NONE: a fresh install as far as everything here is + // concerned, free to start deferred attribution and be handed the + // same inviter back. Leaving the baseline alone is what makes the + // erasure happen again instead. + Log.p("invite: the erasure marker could not be persisted; it will be applied " + + "again rather than reported as done", Log.WARNING); + return false; } + state = STATE_NONE_FOUND; + stateLoaded = true; + return true; } // Package private: called from the provider when consent changes. @@ -1950,6 +1965,18 @@ private static void onEdt(Runnable r) { private static void requestMatch(Map pending) { if (!explicitlyAllowed()) { + // Nothing is outstanding after this, and saying so is what lets a + // later grant act immediately. + // + // Under OPT_OUT with no choice on record the referrer read IS + // permitted, so a Play install that comes back empty falls through + // to here -- where the statistical match needs an explicit grant + // and declines. The referrer's own lookupIssuedAt was still set, so + // onConsentChanged() saw a lookup in flight, did not start the + // match the grant had just permitted, and nothing retried it: the + // attribution stayed pending until some unrelated flush, check or + // relaunch happened along. + lookupIssuedAt = 0; return; } bumpAttempts(pending); diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index 8954ffa4318..c879d25ddc9 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -230,7 +230,7 @@ When attribution resolves it's also written as persistent analytics dimensions ( The App Store carries no referrer parameter of its own, so on iOS a deferred install -- one where the friend didn't already have the app -- can only be matched statistically, on a coarse device profile within a short window. That match is occasionally wrong, and `getConfidence()` reports how much to trust it. Report it as an estimate, and don't pay a referral bounty on it without saying so. -`Invites.setAttributionWindow(0)` switches deferred attribution off entirely if you would rather not use it. +`Invites.setAttributionWindow(0)` switches the statistical match off if you would rather not use it. It doesn't turn deferred attribution off altogether: an exact code the device is already holding -- one that came back through the Play install referrer, or arrived on a link -- is still claimed and still reported, because there's nothing to guess about it. What the window governs is the match that needs a window to mean anything. ==== Consent diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 6877aea9e95..f1cae75a37b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -779,6 +779,40 @@ static List readMediaPermissionNames(boolean blocked, "com/codename1/vpn/tunnel/", }; + /// The invite entry points, for the same library scan. + /// + /// Both of them, because an application can reference either alone: the + /// button without the facade, or the facade without the button. The second + /// is an exact class rather than a package, and matching it as a prefix is + /// the same answer -- a class name starts with itself. + private static final String[] INVITE_LIB_PREFIXES = { + "com/codename1/analytics/invite/", + "com/codename1/components/InviteButton", + }; + + /// Folds invite usage found inside submitted libraries into the scanner's + /// flags. + /// + /// A library that encapsulates invites was invisible to the scan over the + /// application's own classes, so usesInvites stayed false and every part of + /// the Android integration went missing at once: no App Links filter, no + /// onNewIntent splice, the install-referrer package deleted from the + /// generated sources, and the Play Install Referrer dependency never + /// selected. The library compiled against an API nothing had switched on. + /// + /// @param libsDir the submitted-libraries folder + /// @return the prefixes found, for the feature catalog + private java.util.Set foldInInviteLibraryUsage(java.io.File libsDir) { + java.util.Set found = + LibraryClassPrefixScan.prefixesFound(libsDir, INVITE_LIB_PREFIXES); + if (found.isEmpty()) { + return found; + } + debug("Invite usage found inside a submitted library: " + found); + usesInvites = true; + return found; + } + /// Folds call and VPN usage found inside submitted libraries into the /// scanner's flags. /// @@ -2665,6 +2699,14 @@ public void usesClassMethod(String cls, String method) { for (String callVpnPrefix : callVpnFromLibraries) { aiAcc.consume(callVpnPrefix); } + // Invites, for the same two reasons. The flag decides the App Links + // filter, the onNewIntent splice and whether the install-referrer + // package survives; the CATALOG is what adds the Play Install Referrer + // dependency and lifts minSdk to 21. Setting only the flag left the + // referrer sources in the project with nothing to compile them against. + for (String invitePrefix : foldInInviteLibraryUsage(libsDir)) { + aiAcc.consume(invitePrefix); + } NearbyManifestFragments.NearbyUsage libraryNearby = NearbyManifestFragments.scanForNearbyUsage(libsDir); if (!libraryNearby.isEmpty()) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java index b4e1633288c..7752eafe791 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java @@ -139,6 +139,26 @@ void standardLaunchModeIsWarnedAboutRatherThanRefused() throws IOException { "standard launch mode still fails the build"); } + @Test + void inviteUsageInsideAsubmittedLibraryIsFoundToo() throws IOException { + // The scan over the application's own classes cannot see a cn1lib that + // encapsulates invites, so usesInvites stayed false and the whole + // Android integration went missing at once: no App Links filter, no + // onNewIntent splice, the install-referrer package deleted from the + // generated sources, and the Play Install Referrer dependency never + // selected. The library compiled against an API nothing switched on. + String source = source(); + assertTrue(source.contains("INVITE_LIB_PREFIXES"), + "invite prefixes are not scanned inside submitted libraries"); + assertTrue(source.contains("foldInInviteLibraryUsage"), + "the library scan does not fold into usesInvites"); + // Fed to the CATALOG as well as to the flag: the flag decides the + // manifest and the sources, the catalog adds the dependency and the + // API 21 floor. + assertTrue(source.contains("for (String invitePrefix : foldInInviteLibraryUsage(libsDir))"), + "the library prefixes never reach the feature catalog"); + } + @Test void theInviteReferenceOnlyExistsForAppsThatUseInvites() throws IOException { String source = source(); From e629d9ef14bb91eee3028063348bb4d03ac9cbd0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:46:10 +0300 Subject: [PATCH 39/70] Invites: an erasure that is checked, and an answer that can arrive too late Storage.deleteStorageFile reports nothing useful on either port that matters -- Android's Context.deleteFile() and JavaSE's File.delete() both return a boolean and neither throws -- so a delete that failed looked exactly like one that worked. reset() cleared the caches regardless, the tombstone was written, and the provider recorded the new client id as fully erased while the attribution record was still on the disk. It came back on the next launch, so getAttribution() and conversion() reported the old referral identity under the new id and a later consent change restored its dimensions. InviteStore.delete() now re-checks existence rather than trusting the call, and OVERWRITES a record that survives with an empty one -- a delete that cannot happen at least leaves nothing behind to restore. reset() keeps its public signature and resetVerified() carries the answer to the one caller that needs it: an erasure is not reported complete unless the attribution record is verifiably gone, and the provider's baseline only moves when it is. Separately, a fingerprint request issued just before expiresAt can sit in the queue or on the wire past it, and only the CURRENT window was checked -- so a late statistical answer resolved and reported invite_install outside the window the application configured. The request carries no expiry to the server, so the record on this device is the only place that deadline exists; it is read against the answer now. A marker with no expiry at all is left alone, being a record from before the window was written rather than one that has run out. Both have tests, each checked by reverting its fix, and both needed a new delete-failure seam in InviteStore for the same reason the write seam exists: a full or read-only store cannot be produced from a test. --- .../analytics/invite/InviteStore.java | 50 +++++++++++++- .../codename1/analytics/invite/Invites.java | 66 +++++++++++++++++-- .../invite/InviteConsentAndErasureTest.java | 22 +++++++ .../invite/InviteResilienceTest.java | 26 ++++++++ 4 files changed, 156 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index ec6388a8b6d..4310650d52c 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -110,6 +110,14 @@ static void failNextWriteForTest(String name) { failNextNamed = name; } + // The same seam for a delete. Storage.deleteStorageFile cannot be made to + // fail from a test either, and the erasure path turns on exactly that. + private static String failNextDeleteNamed; + + static void failNextDeleteForTest(String name) { + failNextDeleteNamed = name; + } + static boolean write(String record, Map values) { if (record != null && record.equals(failNextNamed)) { failNextNamed = null; @@ -127,14 +135,50 @@ static boolean write(String record, Map values) { } } - static void delete(String record) { + /// Deletes a record and says whether it is really gone. + /// + /// `deleteStorageFile` reports nothing useful on either port that matters + /// -- Android's `Context.deleteFile()` and JavaSE's `File.delete()` both + /// return a boolean and neither throws -- so a failed delete looked + /// identical to a successful one. That is load bearing for erasure: the + /// caller went on to report the identity erased while the record was still + /// on the disk, ready to come back on the next launch. + /// + /// Existence is re-checked afterwards rather than trusted, and a record + /// that survives is OVERWRITTEN with an empty one. An empty record carries + /// no code, no inviter and no campaign, so a delete that cannot happen at + /// least leaves nothing behind to restore. + /// + /// - `record`: the record name + /// + /// #### Returns + /// + /// true when nothing readable is left + static boolean delete(String record) { + if (record != null && record.equals(failNextDeleteNamed)) { + failNextDeleteNamed = null; + return false; + } try { Storage s = Storage.getInstance(); - if (s != null && s.exists(record)) { - s.deleteStorageFile(record); + if (s == null) { + return false; } + if (!s.exists(record)) { + return true; + } + s.deleteStorageFile(record); + if (!s.exists(record)) { + return true; + } + if (!s.writeObject(record, new LinkedHashMap())) { + return false; + } + Map left = read(record); + return left == null || left.isEmpty(); } catch (Throwable t) { Log.e(t); + return false; } } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index b23f1eef57d..7056ec69de1 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -966,10 +966,28 @@ public static void flush() { /// that left the referral dimensions behind would re-link the fresh /// identity to the same inviter. public static void reset() { + resetVerified(); + } + + /// The same work, reporting whether the durable records really went. + /// + /// Package private and separate so `reset()` keeps the signature an + /// application already calls. The answer matters to exactly one caller: + /// an erasure must not be reported complete while the attribution record + /// is still readable, or it comes back on the next launch under the new + /// identity. + /// + /// #### Returns + /// + /// true when nothing readable is left behind + static boolean resetVerified() { lookupEpoch++; - InviteStore.delete(InviteStore.PENDING); + boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); - InviteStore.delete(InviteStore.ATTRIBUTION); + // ATTRIBUTION is the one that matters: it names the inviter. The other + // two are a lookup in progress and a queue of registrations, neither of + // which identifies anybody after this. + cleared &= InviteStore.delete(InviteStore.ATTRIBUTION); InviteStore.delete(InviteStore.OUTBOX); Preferences.delete(PREF_CONSUMED_ARG); clearDimensions(); @@ -984,6 +1002,7 @@ public static void reset() { lookupIssuedAt = 0; undelivered = null; unacknowledged.clear(); + return cleared; } // Package private test seam: the epoch an outstanding lookup was issued @@ -1003,7 +1022,23 @@ static void forgetCachedAttributionForTest() { // Package private: the analytics provider hook calls this when the client // id changes underneath us, which is what an erasure request looks like. static boolean eraseInternal() { - reset(); + // The DELETES have to have happened, not just been attempted. + // + // Storage.deleteStorageFile reports nothing useful: Android's + // Context.deleteFile() and JavaSE's File.delete() both return a boolean + // and neither throws, so a delete that failed looked exactly like one + // that worked. reset() cleared the caches regardless, the tombstone was + // written, and the provider recorded the new client id as fully + // erased -- while the old attribution record was still on the disk. It + // came back on the next launch, so getAttribution() and conversion() + // reported the old referral identity under the new id, and a later + // consent change restored its dimensions. + boolean cleared = resetVerified(); + if (!cleared) { + Log.p("invite: the attribution record could not be deleted, so the erasure is " + + "not complete and will be attempted again", Log.WARNING); + return false; + } // A tombstone, so the erasure is not undone by the next ordinary // launch. // @@ -2197,8 +2232,29 @@ static void handleResolution(String payload, String matchType, boolean deferred, // deferred -- the code came back through the store, which is the whole // reason the Android path is the deterministic one -- so the kill // switch dropped the best answer the device will ever have. - if (MATCH_FINGERPRINT.equals(matchType) && attributionWindow == 0) { - return; + if (MATCH_FINGERPRINT.equals(matchType)) { + if (attributionWindow == 0) { + return; + } + // And the window has to still be open when the ANSWER arrives. + // + // A request issued just before expiresAt can sit in the queue or on + // the wire past it, and only the current window was checked -- so a + // late statistical answer resolved and reported invite_install + // outside the window the application configured. The request does + // not carry the expiry to the server either, so the server cannot + // refuse it on our behalf; the record on this device is the only + // place the deadline exists. + // + // Read from the pending record rather than recomputed, because it + // is the deadline this lookup was started under -- and a marker + // with no expiry at all is left alone, since that is a record from + // before the window was written rather than one that has run out. + Map deadline = readPending(); + long expiresAt = InviteStore.getLong(deadline, "expiresAt", 0); + if (expiresAt > 0 && System.currentTimeMillis() > expiresAt) { + return; + } } try { if (payload == null || payload.length() == 0) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index c68e1fe7f27..cf3340e2621 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -156,6 +156,28 @@ void anerasedInstallDoesNotStartLookingAgainByItself() { "a direct invite could not reopen attribution after an erasure"); } + @FormTest + void anerasureIsNotReportedDoneWhileTheAttributionSurvives() { + // Storage.deleteStorageFile reports nothing useful: Android's + // Context.deleteFile() and JavaSE's File.delete() both return a boolean + // and neither throws, so a delete that failed looked exactly like one + // that worked. The caches were cleared regardless, the tombstone was + // written, and the provider recorded the new client id as fully + // erased -- while the attribution record was still on the disk, ready + // to come back on the next launch and report the old referral identity + // under the new id. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution()); + + InviteStore.failNextDeleteForTest(InviteStore.ATTRIBUTION); + assertFalse(Invites.eraseInternal(), + "an erasure reported success while the attribution record survived"); + } + @FormTest void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 570339f3c10..ee07bfda0fb 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1344,6 +1344,32 @@ void thekillSwitchStillLetsAnInstallReferrerClaimLand() { assertEquals("REF9", a.getCode()); } + @Test + @EdtTest + void afingerprintAnswerThatArrivesAfterTheWindowIsRefused() { + // A request issued just before expiresAt can sit in the queue or on the + // wire past it, and only the CURRENT window was checked -- so a late + // statistical answer resolved and reported invite_install outside the + // window the application configured. The request carries no expiry to + // the server either, so the record on this device is the only place + // that deadline exists. + Invites.checkForInvite(); + int inFlight = Invites.currentLookupEpochForTest(); + + // The window closes while the answer is on the wire. + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending); + pending.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); + assertTrue(InviteStore.write(InviteStore.PENDING, pending)); + Invites.forgetLoadedState(); + + Invites.handleResolution(InviteTestSupport.resolvedJson("LATE2", "c1", "sms"), + Invites.MATCH_FINGERPRINT, true, inFlight); + + assertNull(Invites.getAttribution(), + "a statistical answer landed after the attribution window closed"); + } + @Test @EdtTest void turningOnReattributionLetsTheStateBeReadAgain() { From 3330a860ea7fcb22fe4c3caa5af2a281baff14d3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:13:52 +0300 Subject: [PATCH 40/70] Invites: the erasure has to take the outbox, and a refusal has to settle resetVerified() gated on the attribution record and ignored the outbox. The outbox holds the queued registration JSON, and that carries the OLD client id along with the campaign, payload and preview -- so if the store rejected deleting AND overwriting it, the erasure still reported success, the provider advanced its baseline, and the next drainOutbox() sent a pre-erasure registration under the new identity as soon as storage recovered. Both identifying records are gated now; the pending record is a lookup in progress and identifies nobody, so it is not. And refusing a late fingerprint answer left the lookup stranded. The ordinary flow makes one asynchronous request with no timer behind it, so returning without terminalising left the install STATE_PENDING for ever and the listener owed an answer it would never get -- unless the application happened to call flush() or checkForInvite() itself. It is settled with REASON_EXPIRED now, and a replacement is abandoned instead, for the reason abandonReplacement() already gives: the earlier attribution still stands, and telling a listener "no invite" about an install it has already been told about is a contradiction rather than an answer. Both were introduced by the fixes in the two commits before them, and both have tests checked by reverting them. --- .../codename1/analytics/invite/Invites.java | 37 ++++++++++++++++--- .../invite/InviteConsentAndErasureTest.java | 18 +++++++++ .../invite/InviteResilienceTest.java | 9 +++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 7056ec69de1..adba5fa4fb7 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -982,13 +982,23 @@ public static void reset() { /// true when nothing readable is left behind static boolean resetVerified() { lookupEpoch++; - boolean cleared = InviteStore.delete(InviteStore.PENDING); + InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); - // ATTRIBUTION is the one that matters: it names the inviter. The other - // two are a lookup in progress and a queue of registrations, neither of - // which identifies anybody after this. + boolean cleared = true; + // ATTRIBUTION names the inviter, and the OUTBOX is the queued + // registration JSON -- which carries the OLD client id along with the + // campaign, payload and preview. Both have to actually go. + // + // Ignoring the outbox result was a hole the size of the whole erasure: + // if the store rejected deleting and overwriting it, the erasure still + // reported success and the provider advanced its baseline, and the next + // drainOutbox() transmitted a pre-erasure registration under the new + // identity once storage recovered. + // + // The pending record is a lookup in progress and identifies nobody + // after this, so it is deleted without gating on it. cleared &= InviteStore.delete(InviteStore.ATTRIBUTION); - InviteStore.delete(InviteStore.OUTBOX); + cleared &= InviteStore.delete(InviteStore.OUTBOX); Preferences.delete(PREF_CONSUMED_ARG); clearDimensions(); resolved = null; @@ -2253,6 +2263,23 @@ static void handleResolution(String payload, String matchType, boolean deferred, Map deadline = readPending(); long expiresAt = InviteStore.getLong(deadline, "expiresAt", 0); if (expiresAt > 0 && System.currentTimeMillis() > expiresAt) { + // SETTLED, not just refused. + // + // The ordinary flow makes this one asynchronous request and has + // no timer behind it, so returning here left the install + // STATE_PENDING for ever: the window had closed, the answer had + // been thrown away, and nothing would ask again unless the + // application happened to call flush() or checkForInvite() + // itself. The listener was owed an answer and never got one. + // + // A replacement is abandoned rather than settled, for the + // reason abandonReplacement() gives: the earlier attribution + // still stands, and telling a listener "no invite" about an + // install it has already been told about is a contradiction + // rather than an answer. + if (!abandonReplacement() && markTerminal(REASON_EXPIRED)) { + notifyUnavailable(REASON_EXPIRED); + } return; } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index cf3340e2621..327d2bfa249 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -178,6 +178,24 @@ void anerasureIsNotReportedDoneWhileTheAttributionSurvives() { "an erasure reported success while the attribution record survived"); } + @FormTest + void anerasureIsNotReportedDoneWhileTheOutboxSurvives() { + // The outbox holds the queued registration JSON, and that carries the + // OLD client id along with the campaign, payload and preview. Ignoring + // its delete result was a hole the size of the whole erasure: the + // erasure reported success, the provider advanced its baseline, and the + // next drainOutbox() transmitted a pre-erasure registration under the + // new identity as soon as storage recovered. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.create(InviteRequest.create().campaign("launch").build()); + assertFalse(InviteStore.readOutbox().isEmpty(), "the fixture queued nothing"); + + InviteStore.failNextDeleteForTest(InviteStore.OUTBOX); + assertFalse(Invites.eraseInternal(), + "an erasure reported success while the queued registration survived"); + } + @FormTest void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index ee07bfda0fb..d13bc3e0f97 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1368,6 +1368,15 @@ void afingerprintAnswerThatArrivesAfterTheWindowIsRefused() { assertNull(Invites.getAttribution(), "a statistical answer landed after the attribution window closed"); + // And the lookup is SETTLED, not left hanging. The ordinary flow makes + // one asynchronous request and has no timer behind it, so refusing the + // answer without terminalising left the install pending for ever and + // the listener owed an answer it would never get. + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "refusing a late answer left the lookup pending for ever"); + assertEquals(Invites.REASON_EXPIRED, + InviteStore.get(InviteStore.read(InviteStore.PENDING), "reason", null), + "the settled lookup does not say why"); } @Test From 25dffdd496b2fc539594bda0a3c9972c460b917c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:39:34 +0300 Subject: [PATCH 41/70] Invites: App Clips replace the statistical match on iOS The App Store carries no referrer of its own, so an iOS install deferred through it could only be guessed at: a coarse device profile written to local storage on first launch, posted to the server, and matched against a hashed fingerprint of somebody's address inside an hour-long window. It was occasionally wrong, it could not say which times, and it required collecting something from people who installed nothing and consented to nothing. An App Clip is launched BY the invite link and receives that link exactly, so it can hand the code to the app the person then installs. The answer is a fact, and everything that existed to make the guess is gone with it. Client: - AppClipHandoffSource / AppClipHandoffCallback, the iOS counterpart of InstallReferrerSource and registered the same way by the build. - requestMatch() becomes requestAppClipHandoff(); the code it returns is claimed with source "app_clip", exactly as a referrer code is. - MATCH_FINGERPRINT becomes MATCH_APP_CLIP, and every match type is now exact -- so the response guard that refused a statistical answer past the kill switch or the window has nothing left to key on and goes. The window still governs where a lookup STARTS. - The device profile is not captured at all any more: no platform, OS version, hardware model, locale or screen size, in storage or on the wire. explicitlyAllowed() went with it, the strict grant having been about transmitting that profile. - No clip source, or a clip with nothing, settles the install as NO_MATCH -- a real and permanent answer -- rather than UNSUPPORTED, which is the reopenable marker the kill switch writes and would have every launch ask again for something that can never be there. Builder: the invite host declares appclips: as well as applinks:. applinks: opens an app that is already installed; appclips: is what lets iOS offer the clip to somebody who does not have it, which is the whole iOS path. Declaring only the first leaves that person on a Safari page. Tests: the three cases that existed for the statistical match are gone, the referrer fallback test now asserts the clip is asked and that nothing is posted, and two new cases cover "the clip had nothing" and "there is no clip on this platform". InviteTestSupport registers a clip source that never answers, which is how "a lookup is outstanding" is still expressible now that no network request is involved. --- .../invite/AppClipHandoffCallback.java | 47 ++++ .../invite/AppClipHandoffSource.java | 65 +++++ .../codename1/analytics/invite/Invites.java | 255 ++++++++---------- docs/developer-guide/Analytics.asciidoc | 12 +- .../com/codename1/builders/IPhoneBuilder.java | 20 +- .../builders/InviteAssociatedDomainTest.java | 28 ++ .../analytics/invite/InviteDeliveryTest.java | 91 ++++++- .../invite/InviteResilienceTest.java | 114 +------- .../analytics/invite/InviteTestSupport.java | 50 ++++ 9 files changed, 420 insertions(+), 262 deletions(-) create mode 100644 CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffCallback.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffCallback.java b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffCallback.java new file mode 100644 index 00000000000..31cd572d9b0 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffCallback.java @@ -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); +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java new file mode 100644 index 00000000000..8db4e0f1e5a --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java @@ -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); +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index adba5fa4fb7..0f6fe711f21 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -42,7 +42,6 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; /// Invite a friend, and follow the invitation through to what it caused. @@ -103,12 +102,18 @@ /// /// ### How exact the answer is /// -/// [InviteAttribution#getMatchType] says how the attribution was made. -/// [#MATCH_DIRECT] and [#MATCH_REFERRER] are exact. [#MATCH_FINGERPRINT] is a -/// statistical match made on the server, used where the platform's store -/// carries no referrer, and it is occasionally wrong -- check -/// [InviteAttribution#getConfidence] and do not pay a referral bounty on it -/// without saying so. +/// [InviteAttribution#getMatchType] says how the attribution was made, and +/// every one of them is exact. [#MATCH_DIRECT] is a link opening an +/// application that was already installed; [#MATCH_REFERRER] is a code that +/// made the whole trip through the Play store; [#MATCH_APP_CLIP] is a code an +/// iOS App Clip received from the link itself and handed to the application it +/// installed. +/// +/// There used to be a statistical match here as well, because the App Store +/// carries no referrer of its own and an iOS install could only be guessed at. +/// It was occasionally wrong, it could not say which times, and it required +/// collecting a hashed profile of people who installed nothing. App Clips made +/// it unnecessary and it is gone. public final class Invites { /// Nothing has been attributed and nothing is outstanding. public static final int STATE_NONE = 0; @@ -132,9 +137,9 @@ public final class Invites { /// came back verbatim. Exact. public static final String MATCH_REFERRER = "referrer"; - /// The server matched this install to a click statistically, because the - /// platform's store carries no referrer. Not exact. - public static final String MATCH_FINGERPRINT = "fingerprint"; + /// An iOS App Clip received the invite link, kept the code, and handed it + /// to the application the person then installed. Exact. + public static final String MATCH_APP_CLIP = "app_clip"; /// No invite matched. The ordinary outcome for an uninvited install. public static final String REASON_NO_MATCH = "no_match"; @@ -215,6 +220,11 @@ public final class Invites { private static boolean reattribution; private static InviteListener listener; private static InstallReferrerSource referrerSource; + + // The iOS counterpart: the code an App Clip left in the container it shares + // with this application. Registered by the build the same way, and absent + // on every platform that has no clip. + private static AppClipHandoffSource appClipSource; private static InviteAttribution resolved; private static boolean attributionLoaded; private static int state = STATE_NONE; @@ -290,6 +300,17 @@ public static void registerInstallReferrerSource(InstallReferrerSource source) { referrerSource = source; } + /// Registers the platform hook that reads the invite code an iOS App Clip + /// left for this application. The Codename One build calls this before the + /// application starts on platforms that have one; an application does not. + /// + /// #### Parameters + /// + /// - `source`: the platform source, or null to remove it + public static void registerAppClipHandoffSource(AppClipHandoffSource source) { + appClipSource = source; + } + // ---- sending --------------------------------------------------------- /// Mints an invite and returns it immediately. @@ -1226,11 +1247,6 @@ private static boolean explicitlyDenied() { return c != null && !c.isAnalytics(); } - private static boolean explicitlyAllowed() { - AnalyticsConsent c = Analytics.getConsent(); - return c != null && c.isAnalytics(); - } - private static String newCode() { byte[] raw = new byte[16]; try { @@ -1663,37 +1679,10 @@ private static Map pendingRecord() { pending.put("expiresAt", String.valueOf(now + attributionWindow)); pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); - captureProfile(pending); writePending(pending); return pending; } - /// Writes the coarse device profile the deferred lookup is matched on. - /// - /// Separate from `pendingRecord()` because it is needed twice. A terminal - /// marker deliberately carries none of it -- a refused profile is deleted, - /// which is the promise the consent path makes -- so a marker that is - /// later reopened has to capture it again rather than restore it. Sending - /// the empty strings and zero dimensions the terminal marker really does - /// hold left the server with the network and the country and nothing else, - /// which scores below the threshold: a consent grant inside the original - /// window could not recover the invite it was granted for. - /// - /// - `record`: the pending record to fill in - private static void captureProfile(Map record) { - Display d = Display.getInstance(); - if (d != null) { - InviteStore.put(record, "platform", d.getPlatformName()); - InviteStore.put(record, "osVersion", d.getProperty("OSVer", "")); - InviteStore.put(record, "deviceModel", - d.getProperty("DeviceHardwareModel", d.getProperty("DeviceName", ""))); - record.put("screenWidth", String.valueOf(d.getDisplayWidth())); - record.put("screenHeight", String.valueOf(d.getDisplayHeight())); - } - Locale loc = Locale.getDefault(); - InviteStore.put(record, "locale", loc == null ? "" : loc.toString()); - } - private static void beginDeferred() { if (deferredStarted) { return; @@ -1743,19 +1732,6 @@ private static void beginDeferred() { marker.put("expiresAt", String.valueOf(began + attributionWindow)); } marker.remove("reason"); - // And the device profile is CAPTURED AGAIN, not restored. - // - // markTerminal() carries the timing, the delivery flag and the - // direct-link code and nothing that describes the device -- - // deliberately, because a refusal deletes the fingerprint. So - // the marker being converted here holds none of it, and the - // resumed requestMatch() sent empty strings and zero screen - // dimensions: the server had the network and the country to - // score on, which is not enough to match, so granting consent - // inside the original window recovered nothing. Recapturing - // costs five property reads and is the same profile the first - // launch would have taken. - captureProfile(marker); writePending(marker); state = STATE_PENDING; stateLoaded = true; @@ -1850,7 +1826,7 @@ private static void beginDeferred() { requestReferrer(source); return; } - requestMatch(pending); + requestAppClipHandoff(pending); } private static boolean safeSupported(InstallReferrerSource source) { @@ -1992,7 +1968,7 @@ private static void fallBackToMatchImpl() { if (pending == null) { return; } - requestMatch(pending); + requestAppClipHandoff(pending); } private static void onEdt(Runnable r) { @@ -2008,32 +1984,76 @@ private static void onEdt(Runnable r) { } } - private static void requestMatch(Map pending) { - if (!explicitlyAllowed()) { - // Nothing is outstanding after this, and saying so is what lets a - // later grant act immediately. + /// Asks the platform whether an App Clip left a code behind. + /// + /// This replaced a statistical match against a hashed device profile. That + /// existed only because the App Store carries no referrer of its own, so an + /// install deferred through it could only be guessed at -- from a coarse + /// profile, a network prefix and an hour-long window, sometimes wrong and + /// never able to say so. A clip is launched BY the invite link and receives + /// it exactly, so the answer is a fact and the guess is gone, along with + /// everything that was collected to make it. + /// + /// No consent gate beyond the ordinary one. The strict grant the match + /// needed was for transmitting a device fingerprint; there is no + /// fingerprint now, and the code this reads is one the person produced + /// themselves by tapping an invite. + /// + /// - `pending`: the pending record, for the attempt budget + private static void requestAppClipHandoff(final Map pending) { + final AppClipHandoffSource source = appClipSource; + if (source == null || !source.isSupported()) { + // No clip on this platform or this build, which is the ordinary + // case: Android answered through the install referrer before + // reaching here, and the desktop and the simulator have neither. // - // Under OPT_OUT with no choice on record the referrer read IS - // permitted, so a Play install that comes back empty falls through - // to here -- where the statistical match needs an explicit grant - // and declines. The referrer's own lookupIssuedAt was still set, so - // onConsentChanged() saw a lookup in flight, did not start the - // match the grant had just permitted, and nothing retried it: the - // attribution stayed pending until some unrelated flush, check or - // relaunch happened along. + // NO_MATCH rather than UNSUPPORTED. This install was not invited -- + // that is a real answer about it, and a permanent one. UNSUPPORTED + // is the reopenable marker the kill switch writes, so reporting it + // here would have every launch reopen a lookup that can never have + // anything to find. lookupIssuedAt = 0; + settleNoHandoff(REASON_NO_MATCH); return; } bumpAttempts(pending); - Map body = identity(); - body.put("platform", InviteStore.get(pending, "platform", "")); - body.put("osVersion", InviteStore.get(pending, "osVersion", "")); - body.put("deviceModel", InviteStore.get(pending, "deviceModel", "")); - body.put("locale", InviteStore.get(pending, "locale", "")); - body.put("screenWidth", Integer.valueOf(InviteStore.getInt(pending, "screenWidth", 0))); - body.put("screenHeight", Integer.valueOf(InviteStore.getInt(pending, "screenHeight", 0))); lookupIssuedAt = System.currentTimeMillis(); - post(getLinkBase() + PATH_MATCH, body, MATCH_FINGERPRINT, true); + source.requestHandoff(new AppClipHandoffCallback() { + public void onHandoff(final String code, final long clickedSeconds) { + onEdt(new Runnable() { + public void run() { + lookupIssuedAt = 0; + if (code == null || code.length() == 0) { + settleNoHandoff(REASON_NO_MATCH); + return; + } + // Claimed exactly as a referrer code is: the trip + // through the store is what makes both of them exact, + // and the server treats them the same way. + claim(code, "app_clip", "", MATCH_APP_CLIP, true); + } + }); + } + + public void onUnavailable(final String reason) { + onEdt(new Runnable() { + public void run() { + lookupIssuedAt = 0; + settleNoHandoff(reason == null ? REASON_NO_MATCH : reason); + } + }); + } + }); + } + + /// Settles an install no clip left anything for, which is most of them. + private static void settleNoHandoff(String reason) { + if (abandonReplacement()) { + return; + } + if (markTerminal(reason)) { + notifyUnavailable(reason); + } } private static void claim(String code, String source, String rawReferrer, @@ -2221,68 +2241,21 @@ static void handleResolution(String payload, String matchType, boolean deferred, if (epoch != lookupEpoch || !allowed()) { return; } - // And the kill switch is read HERE, not only where the lookup starts. + // There is no kill-switch guard on the answer any more, because every + // answer is exact. // - // setAttributionWindow(0) turns off deferred attribution, but a - // statistical request queued a moment earlier is already on the wire - // and carries the epoch it was issued with -- so its answer used to - // land, persist and report an attribution the application had just - // switched off. The window is checked against the answer rather than - // against the request. + // It refused a statistical match that setAttributionWindow(0) had + // switched off, and a statistical match that arrived after the window + // closed. Both were about a guess: a coarse profile matched on the + // server, which could be wrong and could be stale. A referrer code and + // an App Clip code are facts that made the trip through the store, and + // a fact arriving late is still the right answer -- which is why the + // guard had to be keyed on the match type rather than on `deferred` in + // the first place, and why it has nothing left to key on now. // - // Only the STATISTICAL answer. The switch turns off the fingerprint - // lookup, not an exact code the device is holding: hasSavedCode() - // exempts one where the lookup begins, and cancelling an exact claim - // here would break the same exemption from the other end. That is also - // why this is not an epoch bump -- the epoch is global and would - // discard the exact claim with it. - // - // Keyed on the match type rather than on `deferred`, which was the - // first spelling and was wrong: an install-referrer claim is exact AND - // deferred -- the code came back through the store, which is the whole - // reason the Android path is the deterministic one -- so the kill - // switch dropped the best answer the device will ever have. - if (MATCH_FINGERPRINT.equals(matchType)) { - if (attributionWindow == 0) { - return; - } - // And the window has to still be open when the ANSWER arrives. - // - // A request issued just before expiresAt can sit in the queue or on - // the wire past it, and only the current window was checked -- so a - // late statistical answer resolved and reported invite_install - // outside the window the application configured. The request does - // not carry the expiry to the server either, so the server cannot - // refuse it on our behalf; the record on this device is the only - // place the deadline exists. - // - // Read from the pending record rather than recomputed, because it - // is the deadline this lookup was started under -- and a marker - // with no expiry at all is left alone, since that is a record from - // before the window was written rather than one that has run out. - Map deadline = readPending(); - long expiresAt = InviteStore.getLong(deadline, "expiresAt", 0); - if (expiresAt > 0 && System.currentTimeMillis() > expiresAt) { - // SETTLED, not just refused. - // - // The ordinary flow makes this one asynchronous request and has - // no timer behind it, so returning here left the install - // STATE_PENDING for ever: the window had closed, the answer had - // been thrown away, and nothing would ask again unless the - // application happened to call flush() or checkForInvite() - // itself. The listener was owed an answer and never got one. - // - // A replacement is abandoned rather than settled, for the - // reason abandonReplacement() gives: the earlier attribution - // still stands, and telling a listener "no invite" about an - // install it has already been told about is a contradiction - // rather than an answer. - if (!abandonReplacement() && markTerminal(REASON_EXPIRED)) { - notifyUnavailable(REASON_EXPIRED); - } - return; - } - } + // The window still governs where the lookup STARTS: beginDeferred() + // refuses to begin one past the deadline, and hasSavedCode() exempts a + // code already in hand. try { if (payload == null || payload.length() == 0) { return; @@ -2359,10 +2332,12 @@ static void handleResolution(String payload, String matchType, boolean deferred, if (rawScore instanceof Number) { double s = ((Number) rawScore).doubleValue(); score = s > 1d ? s / 100d : s; - } else if (MATCH_FINGERPRINT.equals(matchType)) { - score = 0d; } - if (MATCH_DIRECT.equals(matchType) || MATCH_REFERRER.equals(matchType)) { + // Every match type is exact now, so the score is one whatever the + // server said. It survives because InviteAttribution advertises it + // and an application may read it; it no longer varies. + if (MATCH_DIRECT.equals(matchType) || MATCH_REFERRER.equals(matchType) + || MATCH_APP_CLIP.equals(matchType)) { score = 1d; } Map params = new LinkedHashMap(); diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index c879d25ddc9..e9515de9a01 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -224,19 +224,19 @@ When attribution resolves it's also written as persistent analytics dimensions ( | `MATCH_REFERRER` | The invite code travelled through the app store and came back verbatim. Exact. This is the Android path. -| `MATCH_FINGERPRINT` -| The server matched this install to an earlier click statistically. Not exact. +| `MATCH_APP_CLIP` +| An iOS App Clip received the invite link and handed the code to the app the person then installed. Exact. This is the iOS path. |=== -The App Store carries no referrer parameter of its own, so on iOS a deferred install -- one where the friend didn't already have the app -- can only be matched statistically, on a coarse device profile within a short window. That match is occasionally wrong, and `getConfidence()` reports how much to trust it. Report it as an estimate, and don't pay a referral bounty on it without saying so. +Every match type is exact. The App Store carries no referrer parameter of its own, so on iOS the code travels a different road than it does on Android: tapping an invite link offers an App Clip, the clip is launched by the link itself and so receives the code exactly, and it leaves that code where the full app can read it after installation. Nothing is matched, estimated or guessed, and a referral bounty can be paid on any of these. -`Invites.setAttributionWindow(0)` switches the statistical match off if you would rather not use it. It doesn't turn deferred attribution off altogether: an exact code the device is already holding -- one that came back through the Play install referrer, or arrived on a link -- is still claimed and still reported, because there's nothing to guess about it. What the window governs is the match that needs a window to mean anything. +`Invites.setAttributionWindow(0)` stops a deferred lookup being started at all -- neither the Play install referrer nor the App Clip handoff is read. An exact code the device is already holding, one that arrived on a link, is still claimed: there is nothing to defer about it. ==== Consent -Everything reported here is gated on the analytics consent category, and nothing is transmitted until consent is granted. The statistical match additionally requires an explicit grant: opt-out mode alone isn't enough, because it reports permission with no user choice on record. +Everything reported here is gated on the analytics consent category, and nothing is transmitted until consent is granted. -One thing does happen before consent. On first launch a coarse device profile -- OS version, hardware model, language, screen size -- is written to local storage so a deferred match is still possible if consent arrives in time. It's never transmitted while consent is withheld, and it's deleted outright if consent is refused. There's no alternative that also works: the window in which a deferred match can be made closes long before a typical consent prompt is answered. +Nothing at all is collected about someone who only taps a link. An earlier design wrote a coarse device profile to local storage on first launch -- OS version, hardware model, language, screen size -- so an iOS install could be matched to a click, and the server kept a hashed fingerprint of the visitor's address for seven days to match it against. App Clips made all that unnecessary: the clip is handed the code by the link, so there is nothing to match and nothing to keep. `Analytics.resetClientId()` erases the invite attribution along with the identity, so an erasure request can't leave a fresh pseudonymous id linked to the same inviter. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 643916b5aa6..4b792c452a7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4421,14 +4421,26 @@ public void usesClassMethod(String cls, String method) { if (usesInvites && "true".equals(request.getArg("ios.invite.universalLinks", "true"))) { String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); - String want = "applinks:" + inviteHost; String existingDomains = request.getArg("ios.associatedDomains", ""); - if (!declaresAssociatedDomain(existingDomains, want)) { - String merged = existingDomains.trim().length() == 0 + // TWO prefixes on the same host, and they do different jobs. + // + // applinks: is what opens an INSTALLED app from the link. + // appclips: is what lets iOS offer the App Clip to somebody who + // does not have the app -- which is the whole iOS attribution + // path now, since the clip receives the invite url exactly and + // hands the code to the app the person then installs. Declaring + // only applinks: leaves that person with a Safari page and no + // way to attribute the install that follows. + String[] wanted = {"applinks:" + inviteHost, "appclips:" + inviteHost}; + for (String want : wanted) { + if (declaresAssociatedDomain(existingDomains, want)) { + continue; + } + existingDomains = existingDomains.trim().length() == 0 ? want : existingDomains + "," + want; debug("Invite attribution: adding the associated domain " + want); - request.putArgument("ios.associatedDomains", merged); } + request.putArgument("ios.associatedDomains", existingDomains); } if (request.getArg("ios.associatedDomains", null) != null) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java index a42d1e2e567..2f5b4583b21 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java @@ -82,4 +82,32 @@ void emptyAndNullAreHandled() { assertFalse(IPhoneBuilder.declaresAssociatedDomain(null, WANT)); assertFalse(IPhoneBuilder.declaresAssociatedDomain(WANT, null)); } + + @Test + void bothPrefixesAreDeclaredForTheInviteHost() { + // applinks: opens an INSTALLED app from the link. appclips: is what + // lets iOS offer the App Clip to somebody who does not have the app -- + // and since the clip receives the invite url exactly and hands the code + // to the app the person then installs, that IS the iOS attribution + // path. Declaring only applinks: leaves that person on a Safari page + // with nothing to attribute the install that follows. + String source = builderSource(); + int at = source.indexOf("String[] wanted = {\"applinks:\" + inviteHost"); + assertTrue(at > 0, "the invite host no longer declares both prefixes"); + assertTrue(source.indexOf("\"appclips:\" + inviteHost", at) > at, + "appclips: is not declared, so iOS cannot offer the App Clip"); + } + + /** The builder source, read the way the other codegen tests read it. */ + private static String builderSource() { + try { + java.io.File f = new java.io.File( + "src/main/java/com/codename1/builders/IPhoneBuilder.java"); + assertTrue(f.isFile(), "the builder must be readable: " + f.getAbsolutePath()); + return new String(java.nio.file.Files.readAllBytes(f.toPath()), + java.nio.charset.StandardCharsets.UTF_8); + } catch (java.io.IOException e) { + throw new IllegalStateException(e); + } + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index 46977368ea6..bd2e1d868aa 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -145,7 +145,16 @@ public void requestReferrer(InstallReferrerCallback callback) { } @FormTest - void noStoreReferrerFallsBackToTheStatisticalMatch() { + void noStoreReferrerFallsBackToTheAppClipHandoff() { + // The Android store answered "no referral", so the deferred lookup asks + // the other exact source: the code an iOS App Clip left in the + // container it shares with this application. + // + // It used to fall back to a statistical match -- a coarse device + // profile posted to the server, matched against a hashed click within + // an hour. Nothing is posted now and nothing about the device is read, + // which is why this asserts on the SOURCE rather than on a request + // body: there is no request. InviteTestSupport.freshInstall(); implementation.clearQueuedRequests(); implementation.setAutoProcessConnections(false); @@ -161,19 +170,81 @@ public void requestReferrer(InstallReferrerCallback callback) { Invites.checkForInvite(); - boolean sawMatch = false; + assertTrue(InviteTestSupport.pendingHandoff.wasAsked(), + "the referrer came back empty and nothing asked the App Clip"); for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { - if (implementation.getQueuedRequests().get(i).getUrl().endsWith("/invites/match")) { - sawMatch = true; + assertTrue(!implementation.getQueuedRequests().get(i).getUrl().endsWith("/match"), + "a statistical match was still posted to the server"); + } + + // And the code the clip hands over is claimed exactly, like a referrer. + InviteTestSupport.pendingHandoff.answer("CLIP123"); + boolean sawClaim = false; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + if (implementation.getQueuedRequests().get(i).getUrl().endsWith("/invites/claim")) { String body = implementation.getQueuedRequests().get(i).getRequestBody(); - // The server reads the address off the socket; the client must - // never try to enumerate it. - assertTrue(!body.contains("\"ip\""), body); - assertTrue(body.contains("osVersion"), body); - assertTrue(body.contains("deviceModel"), body); + if (body != null && body.contains("CLIP123")) { + sawClaim = true; + assertTrue(body.contains("app_clip"), body); + // Nothing about the device goes with it. + assertTrue(!body.contains("osVersion"), body); + assertTrue(!body.contains("deviceModel"), body); + } } } - assertTrue(sawMatch, "expected the statistical match as the fallback"); + assertTrue(sawClaim, "the App Clip's code was never claimed"); + } + + @FormTest + void anInstallWithNoClipHandoffIsNotInvited() { + // The overwhelmingly common case: somebody installed the application + // without ever tapping an invite. The clip answers that it has nothing, + // and that is a real and permanent answer about this install -- not + // "unsupported", which is the reopenable marker the kill switch writes + // and would have every launch ask again for something that can never be + // there. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + + Invites.checkForInvite(); + InviteTestSupport.pendingHandoff.answerNothing(Invites.REASON_NO_MATCH); + + assertEquals(Invites.REASON_NO_MATCH, told[0]); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + } + + @FormTest + void aplatformWithNoAppClipSettlesRatherThanWaiting() { + // No clip source at all -- the desktop, the simulator, an iOS build + // without a clip, or Android once the referrer has already answered. + // Settling immediately is what keeps the listener's contract: exactly + // one answer per install, and this install's answer is "no invite". + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.registerAppClipHandoffSource(null); + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + + Invites.checkForInvite(); + + assertEquals(1, told[0], "a platform with no App Clip left the listener waiting"); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); } @FormTest diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index d13bc3e0f97..f5ecc49ebb2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -118,7 +118,7 @@ void aNoMatchAnswerIsNotAskedAgainOnTheNextLaunch() { // queried again, and an ordinary uninvited install kept contacting the // server for ever. Invites.checkForInvite(); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); Invites.forgetLoadedState(); @@ -132,7 +132,7 @@ void theTerminalMarkerKeepsNoDeviceProfile() { // It is durable and it is empty: the profile existed to be matched, // and there is nothing left to match it against. Invites.checkForInvite(); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); Map marker = InviteStore.read(InviteStore.PENDING); assertNotNull(marker, "the answer has to be durable"); assertTrue(marker.containsKey("state")); @@ -256,7 +256,7 @@ void aNoMatchDoesNotSettleTheInstallWhileAReferrerRetryIsOutstanding() { pending.put("referrerRetry", "true"); InviteStore.write(InviteStore.PENDING, pending); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); Invites.forgetLoadedState(); assertEquals(Invites.STATE_PENDING, Invites.getState(), @@ -296,7 +296,7 @@ void aDirectLinkSupersedesADeferredLookupAlreadyOnTheWire() { // The deferred answer arrives late, under the epoch it was issued in. Invites.handleResolution(InviteTestSupport.resolvedJson("GUESS", "c2", "unknown"), - Invites.MATCH_FINGERPRINT, true, deferredEpoch); + Invites.MATCH_APP_CLIP, true, deferredEpoch); InviteAttribution a = Invites.getAttribution(); assertNotNull(a); @@ -328,7 +328,7 @@ public void attributionUnavailable(String reason) { told[0]++; } }); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); assertEquals(0, told[0], "a pending outcome used the terminal callback"); // And the exact answer that arrives afterwards is still deliverable. @@ -359,7 +359,7 @@ public void requestReferrer(InstallReferrerCallback callback) { }); Invites.reset(); Invites.checkForInvite(); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); Invites.forgetLoadedState(); assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), @@ -405,7 +405,7 @@ void flushSupersedesWhateverTheLastAttemptLeftOutstanding() { Invites.MATCH_REFERRER, true); Invites.handleResolution(InviteTestSupport.resolvedJson("GUESS", "c2", "unknown"), - Invites.MATCH_FINGERPRINT, true, stale); + Invites.MATCH_APP_CLIP, true, stale); InviteAttribution a = Invites.getAttribution(); assertNotNull(a); @@ -758,7 +758,7 @@ void aRefusalHeldForALateListenerIsDiscardedWhenTheLookupResumes() { Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); Analytics.setConsent(AnalyticsConsent.granted()); Invites.handleResolution(InviteTestSupport.resolvedJson("RESOLVED1", "c1", "sms"), - Invites.MATCH_FINGERPRINT, true); + Invites.MATCH_APP_CLIP, true); final String[] unavailable = new String[1]; final InviteAttribution[] received = new InviteAttribution[1]; @@ -1021,7 +1021,7 @@ public void attributionUnavailable(String reason) { Analytics.setConsent(AnalyticsConsent.granted()); Invites.handleResolution(InviteTestSupport.resolvedJson("LATER3", "c1", "sms"), - Invites.MATCH_FINGERPRINT, true); + Invites.MATCH_APP_CLIP, true); Invites.forgetLoadedState(); Invites.setInviteListener(null); @@ -1159,7 +1159,7 @@ public void requestReferrer(InstallReferrerCallback callback) { } }); Invites.checkForInvite(); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); Invites.forgetLoadedState(); assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), @@ -1218,41 +1218,6 @@ void reopeningAfterConsentKeepsTheOriginalWindow() { "granting consent restarted the attribution window"); } - @Test - @EdtTest - void reopeningAfterConsentCapturesTheDeviceProfileAgain() { - // The other half of the same reopen. markTerminal() carries the timing, - // the delivery flag and the direct-link code and nothing that describes - // the device -- deliberately, because a refusal deletes the - // fingerprint. So the marker converted back to pending held empty - // strings and zero screen dimensions, and the resumed match sent the - // server the network and the country to score on and nothing else, - // which is below the threshold. Granting consent inside the original - // window could not recover the invite it was granted for. - Invites.checkForInvite(); - Map first = InviteStore.read(InviteStore.PENDING); - assertNotNull(first); - String platform = InviteStore.get(first, "platform", ""); - assertTrue(platform.length() > 0, "the first launch captured no platform"); - - Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); - Map denied = InviteStore.read(InviteStore.PENDING); - assertNotNull(denied); - assertEquals("", InviteStore.get(denied, "platform", ""), - "the refused marker kept a device profile it promised to delete"); - - Analytics.setConsent(AnalyticsConsent.granted()); - - Map resumed = InviteStore.read(InviteStore.PENDING); - assertNotNull(resumed); - assertEquals(platform, InviteStore.get(resumed, "platform", ""), - "the reopened lookup carries no platform, so it cannot match"); - assertTrue(InviteStore.getLong(resumed, "screenWidth", 0) > 0, - "the reopened lookup carries no screen dimensions"); - assertTrue(InviteStore.get(resumed, "locale", "").length() > 0, - "the reopened lookup carries no locale"); - } - @Test @EdtTest void theZeroWindowDoesNotDiscardAnExactCodeWeAreHolding() { @@ -1281,26 +1246,6 @@ public void attributionUnavailable(String reason) { assertEquals(Invites.STATE_PENDING, Invites.getState()); } - @Test - @EdtTest - void thekillSwitchAlsoRefusesAmatchAlreadyOnTheWire() { - // setAttributionWindow(0) changed only the value future calls read. A - // statistical request queued a moment earlier carries the epoch it was - // issued with, so its answer still landed, persisted and reported an - // attribution the application had just switched off. - Invites.checkForInvite(); - assertEquals(Invites.STATE_PENDING, Invites.getState()); - int inFlight = Invites.currentLookupEpochForTest(); - - Invites.setAttributionWindow(0); - - Invites.handleResolution(InviteTestSupport.resolvedJson("LATE1", "c1", "sms"), - Invites.MATCH_FINGERPRINT, true, inFlight); - - assertNull(Invites.getAttribution(), - "a statistical answer landed after the kill switch was thrown"); - } - @Test @EdtTest void thekillSwitchStillLetsAnExactAnswerLand() { @@ -1344,41 +1289,6 @@ void thekillSwitchStillLetsAnInstallReferrerClaimLand() { assertEquals("REF9", a.getCode()); } - @Test - @EdtTest - void afingerprintAnswerThatArrivesAfterTheWindowIsRefused() { - // A request issued just before expiresAt can sit in the queue or on the - // wire past it, and only the CURRENT window was checked -- so a late - // statistical answer resolved and reported invite_install outside the - // window the application configured. The request carries no expiry to - // the server either, so the record on this device is the only place - // that deadline exists. - Invites.checkForInvite(); - int inFlight = Invites.currentLookupEpochForTest(); - - // The window closes while the answer is on the wire. - Map pending = InviteStore.read(InviteStore.PENDING); - assertNotNull(pending); - pending.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); - assertTrue(InviteStore.write(InviteStore.PENDING, pending)); - Invites.forgetLoadedState(); - - Invites.handleResolution(InviteTestSupport.resolvedJson("LATE2", "c1", "sms"), - Invites.MATCH_FINGERPRINT, true, inFlight); - - assertNull(Invites.getAttribution(), - "a statistical answer landed after the attribution window closed"); - // And the lookup is SETTLED, not left hanging. The ordinary flow makes - // one asynchronous request and has no timer behind it, so refusing the - // answer without terminalising left the install pending for ever and - // the listener owed an answer it would never get. - assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), - "refusing a late answer left the lookup pending for ever"); - assertEquals(Invites.REASON_EXPIRED, - InviteStore.get(InviteStore.read(InviteStore.PENDING), "reason", null), - "the settled lookup does not say why"); - } - @Test @EdtTest void turningOnReattributionLetsTheStateBeReadAgain() { @@ -1451,7 +1361,7 @@ public void attributionUnavailable(String reason) { assertEquals(1, told[0], "the refusal was not delivered, so this proves nothing"); Analytics.setConsent(AnalyticsConsent.granted()); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); Invites.forgetLoadedState(); Invites.setInviteListener(null); @@ -1467,7 +1377,7 @@ void aDirectLinkDiscardsAHeldAnswerThatIsNoLongerTrue() { // resolved the stale unavailable result -- with deliveredThisRun then // suppressing the correct one. Invites.checkForInvite(); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); Invites.handleUrl("https://cloud.codenameone.com/i/acme/LATER6"); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index ca6510d232b..aeb105dcb44 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -37,6 +37,48 @@ final class InviteTestSupport { private InviteTestSupport() { } + /** The source freshInstall() leaves registered; holds its callback. */ + static PendingHandoffSource pendingHandoff; + + /** + * An App Clip source that is supported and never answers on its own, so a + * test can decide when -- and whether -- the handoff arrives. + */ + static final class PendingHandoffSource implements AppClipHandoffSource { + private AppClipHandoffCallback callback; + + public boolean isSupported() { + return true; + } + + public void requestHandoff(AppClipHandoffCallback cb) { + callback = cb; + } + + /** True once Invites has asked. */ + boolean wasAsked() { + return callback != null; + } + + /** Delivers a code, as a clip that saw the link would. */ + void answer(String code) { + AppClipHandoffCallback cb = callback; + callback = null; + if (cb != null) { + cb.onHandoff(code, 0L); + } + } + + /** Answers that no clip left anything, which is the common case. */ + void answerNothing(String reason) { + AppClipHandoffCallback cb = callback; + callback = null; + if (cb != null) { + cb.onUnavailable(reason); + } + } + } + static RecordingProvider freshInstall() { clearAppArg(); Analytics.clearProviders(); @@ -48,6 +90,14 @@ static RecordingProvider freshInstall() { Invites.setReattribution(false); Invites.setAttributionWindow(Invites.DEFAULT_ATTRIBUTION_WINDOW); Invites.registerInstallReferrerSource(null); + // A clip source that is present and never answers, which is the state + // the old statistical match left behind: a lookup outstanding with its + // response still to come. Without one, every deferred lookup settles + // the instant it starts -- correct on a device with no App Clip, and + // useless for testing anything that happens while one is in flight. + // A case that wants an answer installs its own. + pendingHandoff = new PendingHandoffSource(); + Invites.registerAppClipHandoffSource(pendingHandoff); Invites.lookupRetryDelay = 30000L; Invites.reset(); Preferences.delete(Invites.PREF_SLUG); From 018119b5c5f09a51b0133cff662491ac71d89b03 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:05:13 +0300 Subject: [PATCH 42/70] Invites: five holes the App Clip path and the erasure left open The pending record is gated by the erasure after all. It looks like bookkeeping -- a state, a deadline, an attempt count -- but it also carries the code a direct link left on the device, and a code names an inviter. A surviving one re-links the new identity to the old invite on the next launch, which is the thing being erased. A failed erasure now blocks the drain. Reporting the failure was not enough on its own: the queued entries carry the OLD client id, so the next flush would transmit exactly what the erasure was asked to prevent as soon as storage recovered -- an erasure that ends by sending the erased identity to the server. flush() retries the erasure first and drains nothing until it succeeds. The App Clip callback checks the epoch it was issued under. The read is asynchronous and everything that supersedes a lookup bumps that epoch, so a code read before a direct link arrived could overwrite the newer exact claim, and the unavailable branch could settle a lookup the answer no longer belonged to. And the clip's code is written down before it is sent. The claim is one fail-silent request, a fresh install is exactly when the device is most likely to be offline, and the clip has already cleared its own copy by the time it answers -- so a code that lived only in the callback was gone for good the moment that request failed. handleUrl() persists a direct code first for the same reason. Also: the compilable developer-guide snippet still named MATCH_FINGERPRINT, which failed the demos build. The first stale-callback test passed without its fix, because an erasure deletes the records the callback would have written to. It is retargeted at the direct-link race, which is the case where the records survive and the epoch is the only thing standing between them. --- .../codename1/analytics/invite/Invites.java | 61 +++++++++++++++++-- .../generated/AnalyticsJava011Snippet.java | 6 +- .../invite/InviteConsentAndErasureTest.java | 24 ++++++++ .../analytics/invite/InviteDeliveryTest.java | 50 +++++++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 0f6fe711f21..57f06cda573 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -225,6 +225,11 @@ public final class Invites { // with this application. Registered by the build the same way, and absent // on every platform that has no clip. private static AppClipHandoffSource appClipSource; + + // Set when an erasure could not remove the durable records, and cleared + // when a later attempt does. Nothing that transmits may run while it is + // set: the records still on the disk describe the identity being erased. + private static boolean erasurePending; private static InviteAttribution resolved; private static boolean attributionLoaded; private static int state = STATE_NONE; @@ -1003,9 +1008,8 @@ public static void reset() { /// true when nothing readable is left behind static boolean resetVerified() { lookupEpoch++; - InviteStore.delete(InviteStore.PENDING); + boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); - boolean cleared = true; // ATTRIBUTION names the inviter, and the OUTBOX is the queued // registration JSON -- which carries the OLD client id along with the // campaign, payload and preview. Both have to actually go. @@ -1016,8 +1020,11 @@ static boolean resetVerified() { // drainOutbox() transmitted a pre-erasure registration under the new // identity once storage recovered. // - // The pending record is a lookup in progress and identifies nobody - // after this, so it is deleted without gating on it. + // The PENDING record is gated too. It looks like bookkeeping -- a + // state, a deadline, an attempt count -- but it also carries the code + // a direct link left on the device, and a code names an inviter. A + // surviving one re-links the new identity to the old invite on the + // next launch, which is the thing being erased. cleared &= InviteStore.delete(InviteStore.ATTRIBUTION); cleared &= InviteStore.delete(InviteStore.OUTBOX); Preferences.delete(PREF_CONSUMED_ARG); @@ -1068,6 +1075,7 @@ static boolean eraseInternal() { if (!cleared) { Log.p("invite: the attribution record could not be deleted, so the erasure is " + "not complete and will be attempted again", Log.WARNING); + erasurePending = true; return false; } // A tombstone, so the erasure is not undone by the next ordinary @@ -1110,10 +1118,12 @@ static boolean eraseInternal() { // erasure happen again instead. Log.p("invite: the erasure marker could not be persisted; it will be applied " + "again rather than reported as done", Log.WARNING); + erasurePending = true; return false; } state = STATE_NONE_FOUND; stateLoaded = true; + erasurePending = false; return true; } @@ -2018,15 +2028,45 @@ private static void requestAppClipHandoff(final Map pending) { } bumpAttempts(pending); lookupIssuedAt = System.currentTimeMillis(); + // The epoch this read was issued under, checked when it answers. + // + // The read is asynchronous, and everything that supersedes a lookup + // bumps the epoch: an erasure, a consent withdrawal, a direct link + // arriving while this was outstanding. Without the check a clip code + // read before an erasure could restore the attribution it removed, or + // overwrite the newer exact claim that superseded it -- and the + // unavailable branch could settle a lookup that is no longer the one + // this answer belongs to. + final int issued = lookupEpoch; source.requestHandoff(new AppClipHandoffCallback() { public void onHandoff(final String code, final long clickedSeconds) { onEdt(new Runnable() { public void run() { + if (issued != lookupEpoch) { + return; + } lookupIssuedAt = 0; if (code == null || code.length() == 0) { settleNoHandoff(REASON_NO_MATCH); return; } + // WRITTEN DOWN before it is sent. + // + // The claim is one fail-silent request. If it does not + // land -- offline first launch, which is exactly when a + // fresh install happens -- the code existed only in + // this callback, the clip had already cleared its own + // copy, and the invite was gone for good. Persisting it + // first is what makes the retry possible, and it is + // what handleUrl() does with a direct code for the same + // reason. + Map record = pendingRecord(); + InviteStore.put(record, "code", code); + record.put("codeSource", "app_clip"); + record.put("codeMatch", MATCH_APP_CLIP); + record.put("codeDeferred", "true"); + record.put("codeReferrer", ""); + writePending(record); // Claimed exactly as a referrer code is: the trip // through the store is what makes both of them exact, // and the server treats them the same way. @@ -2038,6 +2078,9 @@ public void run() { public void onUnavailable(final String reason) { onEdt(new Runnable() { public void run() { + if (issued != lookupEpoch) { + return; + } lookupIssuedAt = 0; settleNoHandoff(reason == null ? REASON_NO_MATCH : reason); } @@ -2665,6 +2708,16 @@ private static void drainOutbox() { if (!allowed()) { return; } + if (erasurePending) { + // An erasure could not delete the queue, and these entries carry + // the OLD client id along with the campaign, payload and preview. + // Sending them once storage recovers is exactly the transmission + // the erasure was asked to prevent, so the erasure is retried and + // nothing is drained until it succeeds. + if (!eraseInternal()) { + return; + } + } List outbox = InviteStore.readOutbox(); if (outbox.isEmpty()) { return; diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava011Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava011Snippet.java index 16858b6c064..8f24fc14dca 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava011Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava011Snippet.java @@ -63,9 +63,9 @@ void snippet() { Invites.setInviteListener(new InviteListener() { public void inviteReceived(InviteAttribution attribution) { // attribution.getCampaign(), getCode(), getPayload() - if (Invites.MATCH_FINGERPRINT.equals(attribution.getMatchType())) { - // A statistical match. Credit it, but do not pay a bounty - // on it without saying so. + if (Invites.MATCH_APP_CLIP.equals(attribution.getMatchType())) { + // An iOS App Clip received the link and handed the code + // over. Exact, like every other match type. } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index 327d2bfa249..c356efd8c6d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -196,6 +196,30 @@ void anerasureIsNotReportedDoneWhileTheOutboxSurvives() { "an erasure reported success while the queued registration survived"); } + @FormTest + void asurvivingOutboxIsNotDrainedUntilTheErasureFinishes() { + // Reporting the failure was not enough on its own. The entries carry + // the OLD client id, so the next flush would transmit exactly what the + // erasure was asked to prevent as soon as storage recovered -- an + // erasure that ends by sending the erased identity to the server. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.create(InviteRequest.create().campaign("launch").build()); + assertFalse(InviteStore.readOutbox().isEmpty(), "the fixture queued nothing"); + + InviteStore.failNextDeleteForTest(InviteStore.OUTBOX); + assertFalse(Invites.eraseInternal(), "the fixture's erasure did not fail"); + + implementation.clearQueuedRequests(); + Invites.flush(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "a pre-erasure registration was transmitted after the erasure failed"); + // And the retry inside flush() finished the job, so the queue is gone. + assertTrue(InviteStore.readOutbox().isEmpty(), + "the erasure was never retried"); + } + @FormTest void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index bd2e1d868aa..9798c800862 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -22,6 +22,9 @@ */ package com.codename1.analytics.invite; +import com.codename1.analytics.Analytics; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertNull; import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; import java.util.ArrayList; @@ -247,6 +250,53 @@ public void attributionUnavailable(String reason) { assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); } + @FormTest + void aclipCodeIsWrittenDownBeforeItIsSent() { + // The claim is one fail-silent request, and a fresh install is exactly + // when the device is most likely to be offline. The clip has already + // cleared its own copy by the time it answers, so a code that lived + // only in the callback was gone for good the moment that request + // failed. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + Invites.checkForInvite(); + InviteTestSupport.pendingHandoff.answer("CLIPSAVE"); + + Map record = InviteStore.read(InviteStore.PENDING); + assertNotNull(record); + assertEquals("CLIPSAVE", InviteStore.get(record, "code", null), + "the clip's code was never written down, so a failed claim loses it"); + assertEquals(Invites.MATCH_APP_CLIP, InviteStore.get(record, "codeMatch", null), + "the saved code lost its provenance"); + } + + @FormTest + void aclipAnswerThatOutlivedItsLookupIsIgnored() { + // The read is asynchronous and everything that supersedes a lookup + // bumps the epoch. A direct link arriving while the clip read is + // outstanding is the case that shows it: the link is an exact answer + // about THIS install, and a clip code read before it must not overwrite + // the record it just wrote. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + Invites.checkForInvite(); + assertTrue(InviteTestSupport.pendingHandoff.wasAsked()); + + // A link is tapped while the clip read is still outstanding. + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECTWINS")); + assertEquals("DIRECTWINS", + InviteStore.get(InviteStore.read(InviteStore.PENDING), "code", null)); + + // The clip finally answers, with something else. + InviteTestSupport.pendingHandoff.answer("STALECLIP"); + + assertEquals("DIRECTWINS", + InviteStore.get(InviteStore.read(InviteStore.PENDING), "code", null), + "a clip answer from before the link overwrote the newer exact claim"); + } + @FormTest void aSecondLinkDoesNotRewriteTheFirstTouchCohort() { InviteTestSupport.freshInstall(); From 3ef1b8c0dd6e40360bd9acd8435087a3d5675bba Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:44:33 +0300 Subject: [PATCH 43/70] Invites: the App Clip is generated now, not just entitled The iOS half of this feature was an interface with nothing behind it. AppClipHandoffSource was declared, Invites asked it for the code, and no implementation existed and nothing registered one -- so appClipSource stayed null and every iOS install settled as no_match. The associated domain said the app could be offered a clip; there was no clip. Four pieces, and the order they fail in is why each exists: - InviteAppClipBuilder emits the clip: main.m, a UIKit delegate, Info.plist and entitlements. Not a Codename One application -- a clip is capped at 15 MB and must launch instantly, and its whole job finishes before anybody reads the screen. - IPhoneBuilder creates the target. An App Clip is an application bundle with its own product type, so the ruby makes an application and assigns the type afterwards; :app_extension produces a binary Apple rejects at upload. It embeds into AppClips/, not PlugIns/ -- the wrong folder signs, uploads and never launches. - IOSAppClipHandoff and CN1InviteAppClip.m read what the clip left in the shared app group and clear it in the same step, so two launches cannot claim one code. The stub registers it under exactly the condition that produced the clip, as a direct symbol reference: obfuscation renames the class and Class.forName would answer nothing. - The app group is resolved BEFORE the stub is written, because the stub needs it as a literal, and the target generation reads the same field. Deriving it twice let the clip's entitlement and the app's registration disagree, which is a clip that stores a code nothing reads. Every one of these is silent when wrong. Nothing links the two binaries, so the defaults key and its two field names are duplicated between the generator and the reader and are asserted in InviteAppClipBuilderTest. Four review findings, all real: - A failed erasure gated only drainOutbox(). A surviving PENDING record still carried its code, so the next checkForInvite() reloaded it and claimed it under the NEW client id -- the transmission the erasure existed to prevent, made by its own aftermath. settleErasure() now gates the lookup, a tapped link and the enqueue. - create() appended to an outbox that survived an erasure. The retry inside the next drain -- which create() triggers itself through flush() -- deleted the queue whole, the fresh invite with it, and it had reported success so nothing held its code. - Emptying the outbox called deleteStorageFile() and returned success without looking. Shares the verified delete now. - The Play referrer never answered when the service disconnected before setup finished: no callback ever ran, the lookup stayed outstanding and nothing retried until the next cold launch. It reports transient now, once, without burning the once-only flag. --- .../analytics/invite/InviteStore.java | 44 +- .../codename1/analytics/invite/Invites.java | 74 ++- .../referrer/AndroidInstallReferrer.java | 64 ++- .../iOSPort/nativeSources/CN1InviteAppClip.m | 124 +++++ .../CodenameOne_GLViewController.h | 6 + .../codename1/impl/ios/IOSAppClipHandoff.java | 114 +++++ .../src/com/codename1/impl/ios/IOSNative.java | 20 + docs/developer-guide/Analytics.asciidoc | 13 +- .../build/shared/BuildHintsDynamic.java | 5 + .../codename1/build/shared/BuildHintsIos.java | 33 ++ .../com/codename1/builders/IPhoneBuilder.java | 213 ++++++++ .../codename1/util/InviteAppClipBuilder.java | 473 ++++++++++++++++++ .../util/InviteAppClipBuilderTest.java | 178 +++++++ .../invite/InviteConsentAndErasureTest.java | 78 +++ 14 files changed, 1414 insertions(+), 25 deletions(-) create mode 100644 Ports/iOSPort/nativeSources/CN1InviteAppClip.m create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index 4310650d52c..5b9a474b612 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -155,6 +155,23 @@ static boolean write(String record, Map values) { /// /// true when nothing readable is left static boolean delete(String record) { + return deleteVerified(record, new LinkedHashMap()); + } + + /// The delete above, with the shape of the empty replacement left to the + /// caller. + /// + /// Shared with the outbox, which is a `List` and not a `Map`. Writing the + /// wrong one would not be caught by anything -- `readOutbox()` answers an + /// empty queue either way -- but it is what the next writer appends to. + /// + /// - `record`: the record name + /// - `empty`: what to leave behind when the delete cannot happen + /// + /// #### Returns + /// + /// true when nothing readable is left + private static boolean deleteVerified(String record, Object empty) { if (record != null && record.equals(failNextDeleteNamed)) { failNextDeleteNamed = null; return false; @@ -171,11 +188,19 @@ static boolean delete(String record) { if (!s.exists(record)) { return true; } - if (!s.writeObject(record, new LinkedHashMap())) { + if (!s.writeObject(record, empty)) { return false; } - Map left = read(record); - return left == null || left.isEmpty(); + Object left = s.readObject(record); + if (left instanceof Map) { + return ((Map) left).isEmpty(); + } + if (left instanceof List) { + return ((List) left).isEmpty(); + } + // Neither shape came back, so nothing readable is left -- which is + // the question, and is why this is not an error. + return true; } catch (Throwable t) { Log.e(t); return false; @@ -252,10 +277,15 @@ static boolean writeOutbox(List entries) { return false; } if (copy.isEmpty()) { - if (s.exists(OUTBOX)) { - s.deleteStorageFile(OUTBOX); - } - return true; + // Verified, exactly as delete() is, and for the same reason: + // deleteStorageFile() reports nothing useful on either port + // that matters, so a failed delete looked identical to a + // successful one. This is the path that empties the queue when + // the LAST registration is acknowledged, and reporting success + // over a surviving file meant every later flush resent an + // acknowledged registration while isRegistered() went on + // answering false about it. + return deleteVerified(OUTBOX, new ArrayList()); } return s.writeObject(OUTBOX, copy); } catch (Throwable t) { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 57f06cda573..cdc69113d36 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -586,6 +586,14 @@ public static boolean handleUrl(String url) { return false; } ensureProvider(); + // A tapped link is a fresh answer and would ordinarily reopen + // attribution, but not while an erasure is still owed: claiming writes + // a record the failing store cannot erase either, and the claim itself + // carries the surviving old state. The retry usually succeeds, because + // what stopped it was transient. + if (!settleErasure()) { + return false; + } // The same guard beginDeferred() has. checkForInvite() treats a // consumed URL as handled and skips beginDeferred entirely, so without // this a refused user who opened an invite link still had a profile @@ -1127,6 +1135,32 @@ static boolean eraseInternal() { return true; } + /// Retries an erasure that could not finish, and says whether anything + /// else may proceed. + /// + /// `eraseInternal()` sets `erasurePending` when a delete or the tombstone + /// write failed, and what survives on the disk is exactly what the erasure + /// was asked to remove: a code, which names an inviter, and a queued + /// registration carrying the OLD client id. + /// + /// This gate lived only in `drainOutbox()`, which left two ways past it. + /// A lookup read the surviving code and claimed it under the NEW identity, + /// which is the transmission the erasure existed to prevent. And `create()` + /// appended to the surviving queue, after which the retry inside the very + /// next drain deleted the whole queue -- the freshly minted invite with + /// it, reported as enqueued and therefore not held in `unacknowledged`. + /// + /// A retry that fails again means storage is unusable, and the honest + /// answer there is to do nothing rather than write more records that + /// cannot be erased either. + /// + /// #### Returns + /// + /// true when no erasure is outstanding + private static boolean settleErasure() { + return !erasurePending || eraseInternal(); + } + // Package private: called from the provider when consent changes. static void onConsentChanged(boolean allowed) { if (allowed) { @@ -1697,6 +1731,13 @@ private static void beginDeferred() { if (deferredStarted) { return; } + // Before anything is read off the disk. A failed erasure leaves the + // PENDING record there with the code it carried, and the lookup below + // would reload that code and claim it under the new client id -- the + // one thing the erasure was asked to make impossible. + if (!settleErasure()) { + return; + } int s = getState(); // Two terminal markers can stop being true, and both carry the reason // that made them. A window of zero is the documented kill switch and an @@ -2699,6 +2740,23 @@ private static boolean queueRegistration(Invite invite, InviteRequest request) { body.put("parameters", new LinkedHashMap(request.getParameters())); } pendingRegistration = JSONParser.mapToJson(body); + // Settled before the queue is touched, and reported as a failed + // enqueue when it cannot be. + // + // An outbox that survived an erasure is deleted WHOLE by the retry + // inside the next drain -- which create() itself triggers through + // flush() -- so an entry appended to it goes with it. It had reported + // success, so nothing held its code in `unacknowledged` and + // isRegistered() answered true about a registration the server was + // guaranteed never to have seen; its campaign, channel and preview + // were gone for good. + // + // The caller's existing failure path is the right answer here: it + // sends this one registration now if consent permits, and otherwise + // remembers the code as unacknowledged. Neither touches the queue. + if (!settleErasure()) { + return false; + } List outbox = InviteStore.readOutbox(); outbox.add(pendingRegistration); return InviteStore.writeOutbox(outbox); @@ -2708,15 +2766,13 @@ private static void drainOutbox() { if (!allowed()) { return; } - if (erasurePending) { - // An erasure could not delete the queue, and these entries carry - // the OLD client id along with the campaign, payload and preview. - // Sending them once storage recovers is exactly the transmission - // the erasure was asked to prevent, so the erasure is retried and - // nothing is drained until it succeeds. - if (!eraseInternal()) { - return; - } + // An erasure could not delete the queue, and these entries carry the + // OLD client id along with the campaign, payload and preview. Sending + // them once storage recovers is exactly the transmission the erasure + // was asked to prevent, so the erasure is retried and nothing is + // drained until it succeeds. + if (!settleErasure()) { + return; } List outbox = InviteStore.readOutbox(); if (outbox.isEmpty()) { diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index 798405f07f9..a8a437c0cd4 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -51,6 +51,12 @@ public class AndroidInstallReferrer implements InstallReferrerSource { private boolean retried; + // Whether the framework has been given its one answer. The SPI promises + // exactly one call, and the disconnect handler added below can arrive + // after a real answer as easily as instead of one -- ending a connection + // is itself what fires it. + private boolean answered; + @Override public boolean isSupported() { return AndroidNativeUtil.getContext() != null @@ -61,7 +67,7 @@ public boolean isSupported() { public void requestReferrer(InstallReferrerCallback callback) { Context context = AndroidNativeUtil.getContext(); if (context == null) { - callback.onUnavailable(Invites.REASON_UNSUPPORTED); + unavailable(callback, Invites.REASON_UNSUPPORTED); return; } try { @@ -77,6 +83,10 @@ public void requestReferrer(InstallReferrerCallback callback) { private void connect(final InstallReferrerClient client, final InstallReferrerCallback callback) { + // Per ATTEMPT, not per instance. The retry below ends this connection, + // which fires this listener's own disconnect -- and that must not be + // read as the retried connection failing. + final boolean[] superseded = new boolean[1]; client.startConnection(new InstallReferrerStateListener() { @Override public void onInstallReferrerSetupFinished(int responseCode) { @@ -91,6 +101,7 @@ public void onInstallReferrerSetupFinished(int responseCode) { // never going to answer. if (!retried) { retried = true; + superseded[0] = true; close(client); requestReferrer(callback); return; @@ -102,7 +113,7 @@ public void onInstallReferrerSetupFinished(int responseCode) { // skip the deterministic path and fall back to a // statistical guess for a referrer we could have // read exactly. - callback.onUnavailable(Invites.REASON_NO_MATCH); + unavailable(callback, Invites.REASON_NO_MATCH); break; default: // FEATURE_NOT_SUPPORTED is the ordinary answer on a @@ -118,7 +129,7 @@ public void onInstallReferrerSetupFinished(int responseCode) { // Unknown failure: treated as transient, so a later flush // can still read a referrer that is genuinely there. Log.e(t); - callback.onUnavailable(Invites.REASON_NO_MATCH); + unavailable(callback, Invites.REASON_NO_MATCH); } finally { close(client); } @@ -126,9 +137,24 @@ public void onInstallReferrerSetupFinished(int responseCode) { @Override public void onInstallReferrerServiceDisconnected() { - // Deliberately not reconnecting. The one retry above is the - // whole allowance; an automatic reconnect here is how a + // Still deliberately not reconnecting. The one retry above is + // the whole allowance; an automatic reconnect here is how a // background service bind loop starts. + // + // But the exchange has to END, and this was the one path that + // left it open. A service that drops before + // onInstallReferrerSetupFinished() ever runs answered nothing, + // so Invites kept its lookup outstanding and its deferred flag + // set: the application's listener was never told anything, and + // nothing retried until the next cold launch. + // + // Reported as transient, which is what it is -- the once-only + // flag stays unburnt, so a later flush can still read a + // referrer that was there the whole time. + if (superseded[0]) { + return; + } + unavailable(callback, Invites.REASON_NO_MATCH); } }); } @@ -160,22 +186,44 @@ private void deliver(InstallReferrerClient client, InstallReferrerCallback callb // statistical no-match settles the install as organic -- for a // referrer that was there all along and simply could not be read // this once. - callback.onUnavailable(Invites.REASON_NO_MATCH); + unavailable(callback, Invites.REASON_NO_MATCH); return; } Preferences.set(PREF_ATTEMPTED, true); if (referrer == null || referrer.length() == 0) { - callback.onUnavailable(Invites.REASON_NO_MATCH); + unavailable(callback, Invites.REASON_NO_MATCH); return; } - callback.onReferrer(referrer, clickSeconds, beginSeconds); + referrer(callback, referrer, clickSeconds, beginSeconds); } private void finish(InstallReferrerCallback callback, String reason) { Preferences.set(PREF_ATTEMPTED, true); + unavailable(callback, reason); + } + + /// Reports "no referral", at most once. + /// + /// Every terminal path goes through here so the disconnect handler can + /// close an exchange nobody else closed without risking a second answer + /// for one that somebody did. + private void unavailable(InstallReferrerCallback callback, String reason) { + if (answered) { + return; + } + answered = true; callback.onUnavailable(reason); } + private void referrer(InstallReferrerCallback callback, String value, + long clickSeconds, long beginSeconds) { + if (answered) { + return; + } + answered = true; + callback.onReferrer(value, clickSeconds, beginSeconds); + } + private void close(InstallReferrerClient client) { try { client.endConnection(); diff --git a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m new file mode 100644 index 00000000000..04d040793e0 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m @@ -0,0 +1,124 @@ +/* + * 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. + */ + +// Native implementation of IOSNative.isAppClipHandoffSupported(String) and +// .consumeAppClipInviteHandoff(String), which back +// com.codename1.impl.ios.IOSAppClipHandoff. +// +// The App Clip half of the same exchange is generated by IPhoneBuilder into +// the clip's own target (InviteAppClipBuilder); the two agree on the app group, the +// defaults key and the two field names, and on nothing else. Keep them in +// step: the clip is a separate binary that ships inside the application, so a +// mismatch here is not a compile error anywhere -- the code is simply never +// found and every iOS install reads as organic. + +#include "xmlvm.h" +#ifndef NEW_CODENAME_ONE_VM +#include "xmlvm-util.h" +#endif +#import "CodenameOne_GLViewController.h" + +#ifdef CN1_INCLUDE_INVITE_APPCLIP + +#import + +#ifdef NEW_CODENAME_ONE_VM +extern JAVA_OBJECT fromNSString(CODENAME_ONE_THREAD_STATE, NSString* str); +extern NSString* toNSString(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str); +#else +extern JAVA_OBJECT fromNSString(NSString* str); +extern NSString* toNSString(JAVA_OBJECT str); +#endif + +// The one key the clip writes and the application consumes. A dictionary +// rather than a bare string so the click time rides along: the clip is the +// only process that ever knew it, and it is gone by the time the application +// exists. +static NSString * const kCN1InviteHandoffKey = @"cn1-invite-app-clip-handoff"; +static NSString * const kCN1InviteCodeField = @"code"; +static NSString * const kCN1InviteClickedField = @"clicked"; + +// Opened fresh on each call and autoreleased rather than cached in a static. +// The port is built without ARC, and a cached suite would outlive an entitlement +// change across a background relaunch; this is called twice per install at most. +static NSUserDefaults *cn1InviteSuite(NSString *group) { + if (group == nil || group.length == 0) { + return nil; + } + NSUserDefaults *suite = [[[NSUserDefaults alloc] initWithSuiteName:group] autorelease]; + return suite; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppClipHandoffSupported___java_lang_String_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + // initWithSuiteName: answers nil when the process carries no such app + // group, which is exactly the question being asked -- an application built + // without a clip has no group and must report unsupported rather than + // spend an attempt discovering there is nothing to read. + return cn1InviteSuite(toNSString(CN1_THREAD_STATE_PASS_ARG groupObj)) != nil + ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_consumeAppClipInviteHandoff___java_lang_String_R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + NSUserDefaults *suite = cn1InviteSuite(toNSString(CN1_THREAD_STATE_PASS_ARG groupObj)); + if (suite == nil) { + return JAVA_NULL; + } + // objectForKey:, not dictionaryForKey:, because the shared container is + // writable by the clip and a wrong type there must read as "nothing was + // left" rather than raise: dictionaryForKey: answers nil for a non- + // dictionary, but the field reads below would then go to the wrong class. + id stored = [suite objectForKey:kCN1InviteHandoffKey]; + if (![stored isKindOfClass:[NSDictionary class]]) { + if (stored != nil) { + [suite removeObjectForKey:kCN1InviteHandoffKey]; + } + return JAVA_NULL; + } + NSDictionary *handoff = (NSDictionary *)stored; + id codeValue = [handoff objectForKey:kCN1InviteCodeField]; + NSString *code = [codeValue isKindOfClass:[NSString class]] ? (NSString *)codeValue : nil; + id clickedValue = [handoff objectForKey:kCN1InviteClickedField]; + long long clicked = [clickedValue isKindOfClass:[NSNumber class]] + ? [(NSNumber *)clickedValue longLongValue] : 0; + + // Cleared whatever was found, including a malformed record. Read once is + // the contract AppClipHandoffSource states, and it is what stops a second + // launch claiming a code the first already claimed. + [suite removeObjectForKey:kCN1InviteHandoffKey]; + + if (code == nil || code.length == 0) { + return JAVA_NULL; + } + // A newline separates the two, so a code carrying one would be read as a + // code plus garbage. Codes are Crockford base32 and cannot, but the clip + // writes what the link gave it and the link is somebody else's input. + if ([code rangeOfString:@"\n"].location != NSNotFound) { + return JAVA_NULL; + } + NSString *joined = [NSString stringWithFormat:@"%@\n%lld", code, clicked]; + return fromNSString(CN1_THREAD_STATE_PASS_ARG joined); +} + +#endif diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 7116beb106a..533e7d97946 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -290,6 +290,12 @@ BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, // entitlement. //#define CN1_INCLUDE_APPLESIGNIN +// CN1_INCLUDE_INVITE_APPCLIP gates the App Clip invite handoff reader in +// CN1InviteAppClip.m. IPhoneBuilder uncomments this only when it generated +// an App Clip target, which is the only thing that ever writes the shared +// app group container the reader consumes. +//#define CN1_INCLUDE_INVITE_APPCLIP + // CN1_INCLUDE_WEBAUTHN gates the com.codename1.io.webauthn native bridge // (ASAuthorizationPlatformPublicKeyCredentialProvider code in CN1WebAuthn.m, // iOS 16+). IPhoneBuilder uncomments this only when the scanner saw diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java new file mode 100644 index 00000000000..13b911bc367 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java @@ -0,0 +1,114 @@ +/* + * 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.impl.ios; + +import com.codename1.analytics.invite.AppClipHandoffCallback; +import com.codename1.analytics.invite.AppClipHandoffSource; +import com.codename1.analytics.invite.Invites; +import com.codename1.io.Log; + +/// Reads the invite code an App Clip left in the shared app group container. +/// +/// This is the deterministic half of invite attribution on iOS. The App Clip +/// is launched by the invite link itself and receives that link exactly, so it +/// writes the code into the group container before offering the App Store. +/// The code made the whole trip through the store, so nothing here is matched +/// or guessed and nothing about the visitor is collected. +/// +/// Only an iOS build that generated an App Clip registers this, and +/// `IPhoneBuilder` splices that registration into the generated stub under +/// exactly the condition that produced the clip. Nothing else in the port +/// references this class, so a build without invites strips it along with the +/// invite package -- which is deliberate, and is why it names the app group at +/// construction rather than reading a build hint of its own. +public class IOSAppClipHandoff implements AppClipHandoffSource { + private final String appGroup; + + /// Creates a source reading the named app group. + /// + /// #### Parameters + /// + /// - `appGroup`: the `group.` identifier the clip and the application both + /// carry in their entitlements + public IOSAppClipHandoff(String appGroup) { + this.appGroup = appGroup; + } + + public boolean isSupported() { + if (appGroup == null || appGroup.length() == 0) { + return false; + } + try { + return IOSImplementation.nativeInstance + .isAppClipHandoffSupported(appGroup); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + public void requestHandoff(AppClipHandoffCallback callback) { + String handoff; + try { + handoff = IOSImplementation.nativeInstance + .consumeAppClipInviteHandoff(appGroup); + } catch (Throwable t) { + // An unreachable container reads as "no clip ran", never as a + // crash: the application works, it simply has no invite behind it. + Log.e(t); + callback.onUnavailable(Invites.REASON_UNSUPPORTED); + return; + } + if (handoff == null || handoff.length() == 0) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + return; + } + // "\n". Two values in one string because the + // native side clears the container as it reads, so a second call to + // fetch the timestamp would answer nothing. + String code = handoff; + long clicked = 0; + int nl = handoff.indexOf('\n'); + if (nl >= 0) { + code = handoff.substring(0, nl); + clicked = parseSeconds(handoff.substring(nl + 1)); + } + code = code.trim(); + if (code.length() == 0) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + return; + } + callback.onHandoff(code, clicked); + } + + /// A timestamp that will not parse is not worth losing an attribution + /// over: the code is what the claim is made with, and the click time is + /// only reported alongside it. + private static long parseSeconds(String value) { + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException err) { + return 0; + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 5222a9f8f34..fe430b94e41 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -2539,4 +2539,24 @@ native void nearbySendPayload(int requestId, String joinedEndpointIds, /** Stops advertising and browsing and drops every session. */ native void nearbyStopAllTransport(); + + /** + * Whether the app group holding the App Clip invite handoff can be opened + * at all. False when the application carries no such entitlement, which is + * every build that generated no clip. + * + * @param appGroup the group identifier + * @return true when the shared container is reachable + */ + native boolean isAppClipHandoffSupported(String appGroup); + + /** + * Reads the invite handoff an App Clip left behind and clears it in the + * same step, so two launches cannot claim one code. + * + * @param appGroup the group identifier + * @return "code\nclickedSeconds", or null when no clip ran + */ + native String consumeAppClipInviteHandoff(String appGroup); + } diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index e9515de9a01..3c1f8d0d960 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -228,7 +228,7 @@ When attribution resolves it's also written as persistent analytics dimensions ( | An iOS App Clip received the invite link and handed the code to the app the person then installed. Exact. This is the iOS path. |=== -Every match type is exact. The App Store carries no referrer parameter of its own, so on iOS the code travels a different road than it does on Android: tapping an invite link offers an App Clip, the clip is launched by the link itself and so receives the code exactly, and it leaves that code where the full app can read it after installation. Nothing is matched, estimated or guessed, and a referral bounty can be paid on any of these. +Every match type is exact. The App Store carries no referrer parameter of its own, so on iOS the code travels a different road than it does on Android: tapping the invite link offers an App Clip, the clip is launched by the link itself and so receives the code exactly, and it leaves that code where the full app can read it after installation. Nothing is matched, estimated or guessed, and a referral bounty can be paid on any of these. `Invites.setAttributionWindow(0)` stops a deferred lookup being started at all -- neither the Play install referrer nor the App Clip handoff is read. An exact code the device is already holding, one that arrived on a link, is still claimed: there is nothing to defer about it. @@ -244,6 +244,17 @@ Nothing at all is collected about someone who only taps a link. An earlier desig Referencing this package makes the build wire the platform side: an `autoVerify` App Links intent filter on Android, an associated domain on iOS, and the Play Install Referrer dependency. An app that only reports analytics gets none of it. +On iOS it also generates the App Clip, because there is no attribution without one. The clip is a separate binary embedded in your app: a few hundred lines of UIKit that show your app's name, offer to install it, and record the invite code the link handed them. It is not a Codename One application and does not run your code. You do not write it, open it or maintain it. + +Three things the generated clip needs from you, and each fails in its own way: + +* The App Store identifier of your app, in `ios.invite.appStoreId`. Without it the clip still records the code, it simply shows no install sheet -- which is the right behaviour before your first release, when the app does not exist in the store yet. +* An App Group registered on your developer account. The build derives one from your package name and adds it to `ios.app_groups`; override it with `ios.invite.appGroup` if you already have one. A group that is not registered signs cleanly and the clip and the app can then never reach each other, which is the failure with no symptom. +* An App Clip enabled on your App ID, alongside Associated Domains. +* An Advanced App Clip Experience registered in App Store Connect for your invite link prefix. Codename One authorises your clip for the domain; what maps a particular link to a particular clip is that experience, and until it exists tapping an invite link shows no clip card at all. The console shows the prefix to register once invites are switched on. + +Set `ios.invite.appClip` to `false` only if you ship an App Clip of your own. The app then reports every install as organic unless your clip writes the handoff itself. + WARNING: On Android, App Links verification checks the certificate the installed APK is signed with. Under Play App Signing that's Google's key, not your upload key, so add the app-signing SHA-256 from the Play Console to `android.invite.signingFingerprint`. Without it verification fails on every Play install, the link opens the browser instead of your app, and nothing reports an error. [[analytics-migration]] diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDynamic.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDynamic.java index fda89976f9b..ce8763c1036 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDynamic.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDynamic.java @@ -86,6 +86,11 @@ static void register(List h) { + "CN1CallDirectoryExtensionIdentifier the host plist carries -- so an override has to " + "reach both or the app asks the system to reload an identifier nothing installed."); + family(h, "ios.invite.buildSettings.*", "ios", + "Xcode build settings for the generated invite App Clip target. The clip is a " + + "separate application bundle embedded in the app, so its deployment " + + "target and device family are its own and an override reaches only it."); + family(h, "ios.vpn.tunnel.buildSettings.*", "ios", "Xcode build settings for the generated packet tunnel extension target. " + "PRODUCT_BUNDLE_IDENTIFIER is read in two places -- the target's own settings and the " diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index fbc707e4925..1b0d4846d75 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -221,6 +221,39 @@ static void register(List h) { + "Associated Domains capability either way, or invite links open Safari " + "instead of the app with no error reported.")); + h.add(new Hint("ios.invite.appClip") + .group(HintGroup.IOS) + .type(HintType.BOOLEAN) + .def("true") + .platform("ios") + .doc("Whether the build generates and embeds the App Clip that makes invite " + + "attribution exact on iOS. The App Store carries no referrer of its " + + "own, so without the clip an iOS install cannot be attributed at all. " + + "Set it to `false` only if you ship an App Clip of your own; the build " + + "then writes no clip, and the app reports every install as organic " + + "unless your clip writes the handoff itself. Ignored when " + + "`ios.invite.universalLinks` is `false`, because iOS can only offer a " + + "clip for a link the app has an associated domain for.")); + + h.add(new Hint("ios.invite.appGroup") + .group(HintGroup.IOS) + .type(HintType.STRING) + .platform("ios") + .doc("The app group the invite App Clip hands the invite code to the installed " + + "app through. Defaults to `group..cn1invite`, and is " + + "added to `ios.app_groups` automatically. It must start with `group.` " + + "and must be registered on your developer account, or the clip and the " + + "app both sign and neither can read what the other wrote.")); + + h.add(new Hint("ios.invite.appStoreId") + .group(HintGroup.IOS) + .type(HintType.STRING) + .platform("ios") + .doc("The numeric App Store identifier of this app, which the invite App Clip " + + "uses to offer the full app through `SKOverlay`. Leave it unset before " + + "your first release: the clip still records the invite code, it simply " + + "shows no install sheet until the app exists in the store.")); + h.add(new Hint("ios.associatedDomains") .group(HintGroup.IOS) .type(HintType.STRING) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 4b792c452a7..b3203a644d3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -26,6 +26,7 @@ import com.codename1.util.IOSAppIntentsBuilder; import com.codename1.util.IOSCallDirectoryExtensionBuilder; import com.codename1.util.IOSDocumentProviderExtensionBuilder; +import com.codename1.util.InviteAppClipBuilder; import com.codename1.util.IOSVpnTunnelExtensionBuilder; import com.codename1.util.IOSWalletExtensionBuilder; import com.codename1.util.MatterExtensionBuilder; @@ -1431,6 +1432,11 @@ private java.util.Set foldInCallAndVpnLibraryUsage( /// alongside the extension and read again when the target is written. private String matterAppGroup; + /// The app group the invite App Clip hands the code to the application + /// through. Empty when no clip was generated, which is also what the + /// generated stub tests before registering a reader. + private String inviteAppClipGroup = ""; + /// The App Group the Call Directory extension and the app share. private String callDirectoryAppGroup; @@ -3707,6 +3713,22 @@ public void usesClassMethod(String cls, String method) { + inviteSlug.trim() + "\");\n"; } } + resolveInviteAppClipGroup(request); + // The reader for what the App Clip left behind. A direct symbol + // reference, not a name lookup: obfuscation renames the class and + // Class.forName would answer nothing in a release build. + // + // Registered before i.init(), because Invites reads the handoff on its + // first checkForInvite() and a source registered after that has missed + // the only launch that had a code to give. Nothing else in the port + // names IOSAppClipHandoff, so a build without a clip strips it. + String inviteAppClipRegister = ""; + if (inviteAppClipGroup != null && inviteAppClipGroup.length() > 0) { + inviteAppClipRegister = " com.codename1.analytics.invite.Invites" + + ".registerAppClipHandoffSource(new " + + "com.codename1.impl.ios.IOSAppClipHandoff(\"" + + inviteAppClipGroup + "\"));\n"; + } String dbLegacy = databaseLegacyStubProperty(request, usesDatabase); // If the build-time SVG transcoder produced a registry class, weave @@ -3905,6 +3927,7 @@ public void usesClassMethod(String cls, String method) { + " if(!initialized) {\n" + " initialized = true;\n" + firebaseRegisterInstall + + inviteAppClipRegister + svgRegistryInstall + phoneHealthBindingsInstall + " i.init(this);\n" @@ -4443,6 +4466,57 @@ public void usesClassMethod(String cls, String method) { request.putArgument("ios.associatedDomains", existingDomains); } + // The App Clip. This is what makes iOS attribution deterministic: + // the clip is launched BY the invite link and is handed it exactly, + // so it knows the code with certainty and writes it into a + // container the installed application reads. Without it the only + // iOS answer is a statistical match against a profile of somebody + // who installed nothing -- which is what this replaced. + // + // Gated on the same usesInvites scan as everything else here, so a + // second binary, a second provisioning profile and an app group + // land only on an app that asked for invites, and on the same + // universalLinks hint: an app that suppressed the associated + // domain has no way for iOS to offer a clip and would ship one + // that can never launch. + if (inviteAppClipGroup.length() > 0) { + String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + // Already resolved and validated before the stub was written, + // which needed it to decide whether to register a reader at + // all. Re-deriving it here would let the two disagree. + String group = inviteAppClipGroup; + // Entry by entry, never a substring test: group.com.acme.shared + // contains group.com.acme, and deciding the group is already + // present on that basis entitles the clip for one group and the + // application for another -- two processes that sign, install, + // and never meet. + String appGroups = request.getArg("ios.app_groups", ""); + boolean present = false; + for (String candidate : appGroups.split(",")) { + if (candidate.trim().equals(group)) { + present = true; + break; + } + } + if (!present) { + request.putArgument("ios.app_groups", + appGroups.trim().length() == 0 ? group + : appGroups.trim() + "," + group); + } + try { + replaceInFile(new File(buildinRes, + "CodenameOne_GLViewController.h"), + "//#define CN1_INCLUDE_INVITE_APPCLIP", + "#define CN1_INCLUDE_INVITE_APPCLIP"); + } catch (IOException ex) { + throw new BuildException( + "Failed to enable CN1_INCLUDE_INVITE_APPCLIP", ex); + } + debug("Invite attribution: generating the App Clip " + + InviteAppClipBuilder.CLIP_NAME + " for " + inviteHost + + " (app group " + group + ")"); + } + if (request.getArg("ios.associatedDomains", null) != null) { // If the user has provided the ios.associatedDomains build hint, then we will need to // enable handling for these events. @@ -7306,6 +7380,15 @@ && conditionCovers(governingKey, appendWidgetExtensionTargets(appExtensionsBuilder, request, new File(tmpFile, "dist")); } + if (inviteAppClipGroup.length() > 0) { + // Same ordering note: appended after the global deployment-target + // pass, so the clip keeps its own iOS 14 floor -- which is not a + // preference. App Clips do not exist below it, and one built against + // an app targeting less does not launch. + appendInviteAppClipTarget(appExtensionsBuilder, request, + new File(tmpFile, "dist")); + } + if (documentProviderEnabled) { // Same ordering note: appended after the global deployment-target pass, // so the extension keeps its own floor while the app keeps whatever it @@ -12174,6 +12257,136 @@ displayName, embeddedExtensionShortVersion(request), sb.append("}\nend\n"); } + /// Decides whether this build gets an invite App Clip, and under which + /// app group. + /// + /// Called before the stub is written, because the stub is what registers + /// the reader and it needs the group as a literal. The target generation + /// runs much later and reads the same field, so the clip's entitlement and + /// the application's registration cannot disagree -- they did while this + /// was derived twice, and the symptom was a clip that stored a code into a + /// container nothing read. + /// + /// @param request the build request, whose ios.app_groups is left alone + /// here; the enablement block adds the group + private void resolveInviteAppClipGroup(BuildRequest request) throws BuildException { + inviteAppClipGroup = ""; + if (!usesInvites + || !"true".equals(request.getArg("ios.invite.universalLinks", "true")) + || !"true".equals(request.getArg("ios.invite.appClip", "true"))) { + return; + } + String group = request.getArg("ios.invite.appGroup", + InviteAppClipBuilder.defaultAppGroup(request.getPackageName())); + group = group == null ? "" : group.trim(); + if (!group.startsWith("group.")) { + throw new BuildException( + "ios.invite.appGroup must be an app group identifier starting " + + "\"group.\", got \"" + group + "\"."); + } + inviteAppClipGroup = group; + } + + /// Emits the App Clip target into the schemes ruby. + /// + /// Modelled on [#appendMatterExtensionTarget], with one structural + /// difference that is the whole reason this is not an app extension: a + /// clip is a full application bundle with its own product type, and + /// `new_target` has no symbol for that type in every xcodeproj version we + /// might meet -- so it is created as an application and the product type + /// assigned afterwards. An extension's `:app_extension` would produce a + /// binary Apple rejects at upload with a message about the extension + /// point, which names nothing a developer could act on. + /// + /// It also embeds into `AppClips/`, not `PlugIns/`. Copied into the wrong + /// folder the clip signs, uploads and never launches. + /// + /// @param sb the ruby being assembled + /// @param request the build request + /// @param distDir the dist directory the clip's sources are staged under + private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, + File distDir) throws IOException, BuildException { + String name = InviteAppClipBuilder.CLIP_NAME; + String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + String displayName = request.getDisplayName() == null + ? request.getMainClass() : request.getDisplayName(); + IOSWalletExtensionBuilder.writeFileMap( + InviteAppClipBuilder.buildFileMap(request.getPackageName(), + inviteAppClipGroup, inviteHost, displayName, + embeddedExtensionShortVersion(request), + embeddedExtensionBundleVersion(request), + request.getArg("ios.invite.appStoreId", "").trim()), + new File(distDir, name)); + log("Adding invite App Clip target " + name + " (app group " + + inviteAppClipGroup + ")"); + + Map buildSettingsMap = new LinkedHashMap(); + buildSettingsMap.put("PRODUCT_BUNDLE_IDENTIFIER", + InviteAppClipBuilder.bundleId(request.getPackageName())); + buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); + buildSettingsMap.put("INFOPLIST_FILE", name + "/Info.plist"); + buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", name + "/" + name + ".entitlements"); + buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", + InviteAppClipBuilder.DEPLOYMENT_TARGET); + // iPhone only. App Clips do not run on iPad-only or Mac destinations, + // and a clip claiming a family the host does not ship fails validation. + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1"); + buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", + "$(inherited) @executable_path/Frameworks"); + buildSettingsMap.put("SKIP_INSTALL", "YES"); + // The clip is generated, self-contained UIKit and owns no Codename One + // objects, so it is built the way Apple's own template is rather than + // the way the port is. + buildSettingsMap.put("CLANG_ENABLE_OBJC_ARC", "YES"); + buildSettingsMap.put("CLANG_ENABLE_MODULES", "YES"); + buildSettingsMap.put("ASSETCATALOG_COMPILER_APPICON_NAME", ""); + for (String key : request.getArgs()) { + if (key.startsWith("ios.invite.buildSettings.")) { + buildSettingsMap.put( + key.substring("ios.invite.buildSettings.".length()), + request.getArg(key, "")); + } + } + // Guarded so re-running the script does not create a duplicate target; + // the build re-executes fix_xcode_schemes.rb after dependency + // integration. + sb.append("\nif xcproj.targets.find{|e| e.name=='" + name + "'}.nil?\n" + + "clip_target = xcproj.new_target(:application, '" + name + "', :ios, '" + + InviteAppClipBuilder.DEPLOYMENT_TARGET + "')\n" + + "clip_target.product_type = '" + InviteAppClipBuilder.PRODUCT_TYPE + "'\n" + + "clip_target.add_system_framework('UIKit')\n" + // SKOverlay is the install affordance, and it is what carries + // the clip's stored data forward to the installed app. + + "clip_target.add_system_framework('StoreKit')\n" + + "clip_group = xcproj.new_group('" + name + "')\n"); + appendFilesToXcodeProjGroup(sb, new File(distDir, name), "clip_group", "clip_target", + distDir); + sb.append("main_app_target = xcproj.targets.find{|e| e.name==main_class_name}\n" + + "main_app_target.add_dependency(clip_target)\n" + + "fileref = xcproj.groups.find{|e| e.display_name=='Products'}.new_file('" + + name + ".app', \"BUILT_PRODUCTS_DIR\")\n" + + "embed_phase = main_app_target.copy_files_build_phases.find{|p| " + + "p.name=='Embed App Clips'} || " + + "main_app_target.new_copy_files_build_phase('Embed App Clips')\n" + + "embed_phase.build_action_mask = \"2147483647\"\n" + // 16 is the products directory, and the destination path below + // is what puts the clip in AppClips/ rather than beside the + // executable. PlugIns (13) is where extensions go and is wrong + // here: the bundle signs and uploads and the clip never runs. + + "embed_phase.dst_subfolder_spec = \"16\"\n" + + "embed_phase.dst_path = \"$(CONTENTS_FOLDER_PATH)/AppClips\"\n" + + "embed_phase.run_only_for_deployment_postprocessing=\"0\"\n" + + "embed_phase.add_file_reference(fileref)\n" + + "clip_target.build_configurations.each{|e| \n"); + for (String buildSettingKey : buildSettingsMap.keySet()) { + sb.append(" e.build_settings['" + escapeRuby(buildSettingKey) + "'] = \"" + + escapeRubyDoubleQuoted(buildSettingsMap.get(buildSettingKey)) + "\"\n"); + } + sb.append("}\n"); + sb.append("end\n"); + sb.append("xcproj.save(project_file)\n"); + } + private void appendMatterExtensionTarget(StringBuilder sb, BuildRequest request, File distDir) throws IOException, BuildException { String name = MatterExtensionBuilder.EXTENSION_NAME; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java new file mode 100644 index 00000000000..39bb5615d32 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java @@ -0,0 +1,473 @@ +/* + * 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.util; + +import java.io.UnsupportedEncodingException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Generates the App Clip that makes invite attribution deterministic on iOS. + * + *

Why a second binary exists

+ * + *

The App Store carries no referrer parameter. That is a platform fact, not + * a gap in this implementation: an iOS install knows nothing about the link + * that led to it, which is why every other product in this space answers the + * question statistically, by matching a hashed profile of the visitor against + * a hashed profile of the installer. Codename One did that too, once, and it + * collected data about people who had installed nothing and agreed to + * nothing.

+ * + *

An App Clip removes the guess. The clip is launched by the invite link + * itself and is handed that link exactly, so it knows the code with + * certainty. It writes the code into the app group container it shares with + * the full application and offers the App Store. When the person installs, the + * application reads the container and the code has made the whole trip + * intact -- no profile, no window, no probability.

+ * + *

What the generated clip is

+ * + *

Deliberately not a Codename One application. A clip is capped at 15 MB + * uncompressed and must launch instantly, and it has exactly one job that + * finishes before the person reads the screen. So it is a few hundred lines of + * UIKit: one label, one button, and {@code SKOverlay} to offer the full app -- + * which is Apple's own install affordance and the one that carries the clip's + * stored data forward.

+ * + *

Pure static string-building with no build state, so the emitted files can + * be asserted in a unit test rather than only by running a device build. The + * three names it shares with the port's reader -- the defaults key and the two + * field names -- are duplicated in {@code CN1InviteAppClip.m} and must move + * together; nothing links the two binaries, so a mismatch is silent.

+ */ +public final class InviteAppClipBuilder { + + /** Xcode target and folder name of the generated clip. */ + public static final String CLIP_NAME = "CN1InviteClip"; + + /** + * App Clips exist from iOS 14. Named here rather than inherited from the + * application because the application's own floor is lower, and a clip + * built against it does not launch. + */ + public static final String DEPLOYMENT_TARGET = "14.0"; + + /** + * The product type Xcode gives an App Clip. {@code new_target} has no + * symbol for it in every xcodeproj version we might meet, so the ruby + * creates an application and assigns this afterwards. + */ + public static final String PRODUCT_TYPE = + "com.apple.product-type.application.on-demand-install-capable"; + + /** The defaults key the clip writes and {@code CN1InviteAppClip.m} consumes. */ + public static final String HANDOFF_KEY = "cn1-invite-app-clip-handoff"; + + /** The invite code, inside the handoff dictionary. */ + public static final String CODE_FIELD = "code"; + + /** Seconds since the epoch at which the link was tapped. */ + public static final String CLICKED_FIELD = "clicked"; + + private InviteAppClipBuilder() { + } + + /** + * The bundle identifier Apple requires of a clip: the application's own + * with a suffix, so the pair is recognised as one product. + * + * @param packageName the application's bundle identifier + * @return the clip's bundle identifier + */ + public static String bundleId(String packageName) { + return packageName + ".Clip"; + } + + /** + * The app group the clip and the application exchange the code through, + * when the developer named none. + * + *

Derived rather than fixed: an app group is namespaced to a developer + * account, so a constant would collide between two Codename One apps on + * the same account and let one read the other's invites.

+ * + * @param packageName the application's bundle identifier + * @return a {@code group.} identifier + */ + public static String defaultAppGroup(String packageName) { + return "group." + packageName + ".cn1invite"; + } + + /** + * Builds the clip's sources and resources. + * + * @param packageName the application's bundle identifier + * @param appGroup the shared app group, already validated + * @param inviteHost the host the invite links are served from + * @param displayName what the clip card calls the app + * @param shortVersion the host's marketing version + * @param bundleVersion the host's build version + * @param storeItemId the App Store item identifier, or empty when it is + * not known at build time + * @return path to content, in a stable order + */ + public static Map buildFileMap(String packageName, + String appGroup, String inviteHost, String displayName, + String shortVersion, String bundleVersion, String storeItemId) { + Map files = new LinkedHashMap(); + files.put("main.m", utf8(mainSource())); + files.put("CN1InviteClipDelegate.h", utf8(delegateHeader())); + files.put("CN1InviteClipDelegate.m", + utf8(delegateSource(appGroup, displayName, storeItemId))); + files.put("Info.plist", + utf8(infoPlist(displayName, shortVersion, bundleVersion))); + files.put(CLIP_NAME + ".entitlements", + utf8(entitlements(packageName, appGroup, inviteHost))); + return files; + } + + private static String mainSource() { + return "// Generated by Codename One. Do not edit.\n" + + "#import \n" + + "#import \"CN1InviteClipDelegate.h\"\n\n" + + "int main(int argc, char * argv[]) {\n" + + " @autoreleasepool {\n" + + " return UIApplicationMain(argc, argv, nil,\n" + + " NSStringFromClass([CN1InviteClipDelegate class]));\n" + + " }\n" + + "}\n"; + } + + private static String delegateHeader() { + return "// Generated by Codename One. Do not edit.\n" + + "#import \n\n" + + "@interface CN1InviteClipDelegate : UIResponder \n" + + "@property (nonatomic, strong) UIWindow *window;\n" + + "@end\n"; + } + + /** + * The clip itself. + * + *

Two things here are load-bearing and easy to get wrong. The invite + * code is recorded in {@code continueUserActivity}, which on a cold launch + * arrives after {@code didFinishLaunching} -- so the recording + * cannot live in the launch path, and the launch path must tolerate having + * no code yet. And the write is flushed immediately rather than at the + * clip's convenience: a clip is terminated without warning the moment the + * person taps through to the App Store, and an unflushed write is the + * attribution.

+ * + * @param appGroup the shared container + * @param displayName what the card calls the app + * @param storeItemId the numeric App Store id, or empty + * @return the source + */ + private static String delegateSource(String appGroup, String displayName, + String storeItemId) { + StringBuilder sb = new StringBuilder(); + sb.append("// Generated by Codename One. Do not edit.\n") + .append("#import \"CN1InviteClipDelegate.h\"\n") + .append("#import \n\n") + .append("static NSString * const kAppGroup = @\"") + .append(escapeObjC(appGroup)).append("\";\n") + .append("static NSString * const kHandoffKey = @\"") + .append(HANDOFF_KEY).append("\";\n") + .append("static NSString * const kDisplayName = @\"") + .append(escapeObjC(displayName)).append("\";\n") + .append("static NSString * const kStoreItemId = @\"") + .append(escapeObjC(storeItemId)).append("\";\n\n") + .append("@interface CN1InviteClipDelegate ()\n") + .append("@property (nonatomic, strong) UILabel *status;\n") + .append("@end\n\n") + .append("@implementation CN1InviteClipDelegate\n\n"); + + // The code extraction, kept in one function so the clip and any future + // reader of this file can see the whole grammar at once. + sb.append("// /i//, /i/, or ?code=. The last\n") + .append("// non-empty path component after /i/ is the code in both path\n") + .append("// forms, so one rule covers them and a third form would only\n") + .append("// need the query fallback below.\n") + .append("static NSString *cn1InviteCodeFromURL(NSURL *url) {\n") + .append(" if (url == nil) { return nil; }\n") + .append(" NSURLComponents *c = [NSURLComponents componentsWithURL:url\n") + .append(" resolvingAgainstBaseURL:NO];\n") + .append(" for (NSURLQueryItem *item in c.queryItems) {\n") + .append(" if ([item.name isEqualToString:@\"code\"] && item.value.length > 0) {\n") + .append(" return item.value;\n") + .append(" }\n") + .append(" }\n") + .append(" NSMutableArray *parts = [NSMutableArray array];\n") + .append(" for (NSString *p in [c.percentEncodedPath componentsSeparatedByString:@\"/\"]) {\n") + .append(" if (p.length > 0) { [parts addObject:p]; }\n") + .append(" }\n") + .append(" if (parts.count < 2 || ![parts[0] isEqualToString:@\"i\"]) { return nil; }\n") + .append(" NSString *last = [parts lastObject];\n") + .append(" return [last stringByRemovingPercentEncoding];\n") + .append("}\n\n"); + + sb.append("// Only characters an invite code can contain. The url is\n") + .append("// somebody else's input and this value is handed to the\n") + .append("// application, which claims with it -- so it is constrained\n") + .append("// here, where the grammar is known, rather than trusted there.\n") + .append("static BOOL cn1InviteCodeIsWellFormed(NSString *code) {\n") + .append(" if (code.length == 0 || code.length > 64) { return NO; }\n") + .append(" NSCharacterSet *allowed = [NSCharacterSet\n") + .append(" characterSetWithCharactersInString:\n") + .append(" @\"ABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n") + .append(" @\"abcdefghijklmnopqrstuvwxyz0123456789-_\"];\n") + .append(" NSCharacterSet *rejected = [allowed invertedSet];\n") + .append(" return [code rangeOfCharacterFromSet:rejected].location == NSNotFound;\n") + .append("}\n\n"); + + sb.append("- (void)recordInviteFromURL:(NSURL *)url {\n") + .append(" NSString *code = cn1InviteCodeFromURL(url);\n") + .append(" if (!cn1InviteCodeIsWellFormed(code)) { return; }\n") + .append(" NSUserDefaults *suite = [[NSUserDefaults alloc] initWithSuiteName:kAppGroup];\n") + .append(" if (suite == nil) { return; }\n") + .append(" [suite setObject:@{ @\"").append(CODE_FIELD).append("\": code,\n") + .append(" @\"").append(CLICKED_FIELD) + .append("\": @((long long)[[NSDate date] timeIntervalSince1970]) }\n") + .append(" forKey:kHandoffKey];\n") + .append(" // Flushed now. The clip is killed without notice the moment\n") + .append(" // the App Store sheet takes over, and the write IS the\n") + .append(" // attribution -- there is no second chance to make it.\n") + .append(" [suite synchronize];\n") + .append(" self.status.text = [NSString stringWithFormat:\n") + .append(" @\"You were invited to %@\", kDisplayName];\n") + .append("}\n\n"); + + sb.append("- (BOOL)application:(UIApplication *)application\n") + .append(" continueUserActivity:(NSUserActivity *)userActivity\n") + .append(" restorationHandler:(void (^)(NSArray> *))handler {\n") + .append(" if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {\n") + .append(" [self recordInviteFromURL:userActivity.webpageURL];\n") + .append(" }\n") + .append(" return YES;\n") + .append("}\n\n"); + + sb.append("- (BOOL)application:(UIApplication *)application\n") + .append(" didFinishLaunchingWithOptions:(NSDictionary *)options {\n") + .append(" self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds];\n") + .append(" UIViewController *root = [[UIViewController alloc] init];\n") + .append(" root.view.backgroundColor = UIColor.systemBackgroundColor;\n") + .append(" self.status = [[UILabel alloc] init];\n") + .append(" self.status.numberOfLines = 0;\n") + .append(" self.status.textAlignment = NSTextAlignmentCenter;\n") + .append(" self.status.font = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle2];\n") + .append(" self.status.text = kDisplayName;\n") + .append(" self.status.translatesAutoresizingMaskIntoConstraints = NO;\n") + .append(" [root.view addSubview:self.status];\n") + .append(" UIButton *get = [UIButton buttonWithType:UIButtonTypeSystem];\n") + .append(" [get setTitle:@\"Get the app\" forState:UIControlStateNormal];\n") + .append(" get.titleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline];\n") + .append(" [get addTarget:self action:@selector(offerFullApp)\n") + .append(" forControlEvents:UIControlEventTouchUpInside];\n") + .append(" get.translatesAutoresizingMaskIntoConstraints = NO;\n") + .append(" [root.view addSubview:get];\n") + .append(" UILayoutGuide *g = root.view.layoutMarginsGuide;\n") + .append(" [NSLayoutConstraint activateConstraints:@[\n") + .append(" [self.status.centerYAnchor constraintEqualToAnchor:g.centerYAnchor constant:-40],\n") + .append(" [self.status.leadingAnchor constraintEqualToAnchor:g.leadingAnchor],\n") + .append(" [self.status.trailingAnchor constraintEqualToAnchor:g.trailingAnchor],\n") + .append(" [get.topAnchor constraintEqualToAnchor:self.status.bottomAnchor constant:24],\n") + .append(" [get.centerXAnchor constraintEqualToAnchor:g.centerXAnchor]\n") + .append(" ]];\n") + .append(" self.window.rootViewController = root;\n") + .append(" [self.window makeKeyAndVisible];\n") + .append(" // A warm launch delivers the activity in the launch options\n") + .append(" // instead of calling continueUserActivity:, so both are read.\n") + .append(" NSDictionary *activityDict = options[UIApplicationLaunchOptionsUserActivityDictionaryKey];\n") + .append(" for (id value in activityDict.allValues) {\n") + .append(" if ([value isKindOfClass:[NSUserActivity class]]) {\n") + .append(" [self recordInviteFromURL:((NSUserActivity *)value).webpageURL];\n") + .append(" }\n") + .append(" }\n") + .append(" [self offerFullApp];\n") + .append(" return YES;\n") + .append("}\n\n"); + + sb.append("// SKOverlay is Apple's own App Clip install affordance, and the\n") + .append("// only one that carries the clip's stored data to the installed\n") + .append("// app. Without a store id -- which a build before first release\n") + .append("// does not have -- the clip still records the code and simply\n") + .append("// shows no sheet; the handoff works the moment the app exists.\n") + .append("- (void)offerFullApp {\n") + .append(" if (kStoreItemId.length == 0) { return; }\n") + .append(" if (@available(iOS 14.0, *)) {\n") + .append(" SKOverlayAppClipConfiguration *config =\n") + .append(" [[SKOverlayAppClipConfiguration alloc] initWithPosition:SKOverlayPositionBottom];\n") + .append(" SKOverlay *overlay = [[SKOverlay alloc] initWithConfiguration:config];\n") + .append(" UIWindowScene *scene = (UIWindowScene *)self.window.windowScene;\n") + .append(" if (scene != nil) { [overlay presentInScene:scene]; }\n") + .append(" }\n") + .append("}\n\n") + .append("@end\n"); + return sb.toString(); + } + + private static String infoPlist(String displayName, String shortVersion, + String bundleVersion) { + return "\n" + + "\n" + + "\n" + + "\n" + + " CFBundleDevelopmentRegion\n" + + " en\n" + + " CFBundleDisplayName\n" + + " " + escapeXml(displayName) + "\n" + + " CFBundleExecutable\n" + + " $(EXECUTABLE_NAME)\n" + + " CFBundleIdentifier\n" + + " $(PRODUCT_BUNDLE_IDENTIFIER)\n" + + " CFBundleInfoDictionaryVersion\n" + + " 6.0\n" + + " CFBundleName\n" + + " $(PRODUCT_NAME)\n" + + " CFBundlePackageType\n" + + " APPL\n" + // Both versions must equal the host's or archive validation + // rejects the whole app, the same rule the extensions follow. + + " CFBundleShortVersionString\n" + + " " + escapeXml(shortVersion) + "\n" + + " CFBundleVersion\n" + + " " + escapeXml(bundleVersion) + "\n" + + " LSRequiresIPhoneOS\n" + + " \n" + + " NSAppClip\n" + + " \n" + // False deliberately. The ephemeral notification asks for + // permission to message somebody who has installed nothing; + // this clip records a code and offers the store, and has no + // reason to speak to them again. + + " NSAppClipRequestEphemeralUserNotification\n" + + " \n" + + " NSAppClipRequestLocationConfirmation\n" + + " \n" + + " \n" + + " UILaunchScreen\n" + + " \n" + + " UIRequiredDeviceCapabilities\n" + + " \n" + + " armv7\n" + + " \n" + + " UISupportedInterfaceOrientations\n" + + " \n" + + " UIInterfaceOrientationPortrait\n" + + " UIInterfaceOrientationLandscapeLeft\n" + + " UIInterfaceOrientationLandscapeRight\n" + + " \n" + + "\n" + + "\n"; + } + + /** + * The clip's entitlements. + * + *

All three are required and each fails differently when absent. Without + * the parent identifier the clip is not recognised as belonging to the app + * and does not install. Without the associated domain iOS never offers the + * clip for the link, so nothing runs. Without the app group the clip runs, + * shows its card, records nothing, and every install reads as organic -- + * the failure with no symptom.

+ * + * @param packageName the application's bundle identifier + * @param appGroup the shared container + * @param inviteHost the host serving the invite links + * @return the plist + */ + private static String entitlements(String packageName, String appGroup, + String inviteHost) { + return "\n" + + "\n" + + "\n" + + "\n" + + " com.apple.developer.parent-application-identifiers\n" + + " \n" + + " $(AppIdentifierPrefix)" + escapeXml(packageName) + "\n" + + " \n" + + " com.apple.developer.associated-domains\n" + + " \n" + + " appclips:" + escapeXml(inviteHost) + "\n" + + " \n" + + " com.apple.security.application-groups\n" + + " \n" + + " " + escapeXml(appGroup) + "\n" + + " \n" + + "\n" + + "\n"; + } + + /** + * Objective-C string-literal escaping for the handful of values + * interpolated into generated source. + * + *

Not cosmetic: the display name is the developer's, and a quotation + * mark in it would end the literal and leave the rest as code. A newline + * would do the same, so both are removed rather than escaped.

+ * + * @param value the raw value + * @return a value safe inside an {@code @"..."} literal + */ + static String escapeObjC(String value) { + if (value == null) { + return ""; + } + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\').append(c); + } else if (c == '\n' || c == '\r' || c == '\t') { + sb.append(' '); + } else if (c >= ' ' && c < 127) { + sb.append(c); + } else if (c >= 127) { + // The generated file is compiled as UTF-8 and a display name + // legitimately carries accents and CJK; only the control range + // is dropped. + sb.append(c); + } + } + return sb.toString(); + } + + static String escapeXml(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&").replace("<", "<") + .replace(">", ">"); + } + + private static byte[] utf8(String value) { + try { + return value.getBytes("UTF-8"); + } catch (UnsupportedEncodingException impossible) { + throw new IllegalStateException("UTF-8 is unavailable", impossible); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java new file mode 100644 index 00000000000..b18bb8f1da0 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java @@ -0,0 +1,178 @@ +/* + * 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.util; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The generated invite App Clip. +/// +/// Everything asserted here fails silently on a device. The clip is a separate +/// binary, nothing links it to the application, and its entire job finishes +/// before anybody looks at the screen -- so a clip that records nothing looks +/// exactly like a clip that recorded something, and the symptom is an install +/// that reads as organic weeks later in a report. +class InviteAppClipBuilderTest { + + private static final String PKG = "com.example.myapp"; + private static final String GROUP = "group.com.example.myapp.cn1invite"; + + private static Map files() { + return InviteAppClipBuilder.buildFileMap(PKG, GROUP, + "cloud.codenameone.com", "My App", "1.4", "17", "123456789"); + } + + private static String text(Map files, String name) + throws Exception { + byte[] content = files.get(name); + assertNotNull(content, name + " was not generated"); + return new String(content, "UTF-8"); + } + + /// The three names the clip shares with the port's reader + /// (`CN1InviteAppClip.m`). Nothing links the two binaries, so a rename on + /// one side compiles, signs, ships and reads nothing. + @Test + void theHandoffNamesAreTheOnesTheReaderLooksFor() throws Exception { + String src = text(files(), "CN1InviteClipDelegate.m"); + assertEquals("cn1-invite-app-clip-handoff", InviteAppClipBuilder.HANDOFF_KEY); + assertEquals("code", InviteAppClipBuilder.CODE_FIELD); + assertEquals("clicked", InviteAppClipBuilder.CLICKED_FIELD); + assertTrue(src.contains("@\"cn1-invite-app-clip-handoff\""), + "the clip must write the key the reader reads"); + assertTrue(src.contains("@\"code\":"), "the code field name"); + assertTrue(src.contains("@\"clicked\":"), "the click time field name"); + } + + /// The write has to be flushed inside the handler. A clip is terminated + /// without notice the moment the store sheet takes over, and the write IS + /// the attribution. + @Test + void theHandoffIsFlushedBeforeTheClipCanBeKilled() throws Exception { + String src = text(files(), "CN1InviteClipDelegate.m"); + int write = src.indexOf("forKey:kHandoffKey"); + int flush = src.indexOf("[suite synchronize]"); + assertTrue(write > 0 && flush > write, + "the shared container must be synchronized after the write"); + } + + /// A cold launch delivers the activity through continueUserActivity: and a + /// warm one through the launch options. Reading only the first loses every + /// second and subsequent tap, which is most of them. + @Test + void bothActivityDeliveryPathsAreRead() throws Exception { + String src = text(files(), "CN1InviteClipDelegate.m"); + assertTrue(src.contains("continueUserActivity:(NSUserActivity *)userActivity"), + "the cold-launch path"); + assertTrue(src.contains("UIApplicationLaunchOptionsUserActivityDictionaryKey"), + "the warm-launch path"); + } + + /// All three entitlements, because each is absent in a different way. The + /// app group is the one whose absence has no symptom at all. + @Test + void theClipCarriesEveryEntitlementItNeeds() throws Exception { + String ent = text(files(), "CN1InviteClip.entitlements"); + assertTrue(ent.contains("$(AppIdentifierPrefix)com.example.myapp"), + "parent application identifier"); + assertTrue(ent.contains("appclips:cloud.codenameone.com"), + "the associated domain iOS offers the clip for"); + assertTrue(ent.contains(GROUP), "the shared app group"); + } + + /// Archive validation rejects the whole application when an embedded + /// bundle's versions differ from the host's. + @Test + void theVersionsMatchTheHostApplication() throws Exception { + String plist = text(files(), "Info.plist"); + assertTrue(plist.contains("CFBundleShortVersionString\n 1.4"), + plist); + assertTrue(plist.contains("CFBundleVersion\n 17"), + plist); + assertTrue(plist.contains("NSAppClip"), "the clip marker"); + } + + /// Apple requires the clip's bundle identifier to extend the + /// application's; an unrelated one is not recognised as its clip. + @Test + void theBundleIdExtendsTheApplications() { + assertTrue(InviteAppClipBuilder.bundleId(PKG).startsWith(PKG + "."), + InviteAppClipBuilder.bundleId(PKG)); + } + + /// Derived per application. A constant group would be shared by every + /// Codename One app on one developer account, and each could read the + /// others' invite codes. + @Test + void theDefaultAppGroupIsPerApplication() { + assertFalse(InviteAppClipBuilder.defaultAppGroup("com.example.a") + .equals(InviteAppClipBuilder.defaultAppGroup("com.example.b")), + "two applications must not share a container"); + assertTrue(InviteAppClipBuilder.defaultAppGroup(PKG).startsWith("group."), + "an app group identifier must start with group."); + } + + /// A build before the first release has no store id, and that must cost + /// the attribution nothing: the code is still recorded, only the install + /// sheet is absent. + @Test + void noStoreIdStillRecordsTheCode() throws Exception { + Map f = InviteAppClipBuilder.buildFileMap(PKG, GROUP, + "cloud.codenameone.com", "My App", "1.4", "17", ""); + String src = text(f, "CN1InviteClipDelegate.m"); + assertTrue(src.contains("kStoreItemId.length == 0"), + "the overlay must be skipped, not the recording"); + int guard = src.indexOf("kStoreItemId.length == 0"); + int record = src.indexOf("- (void)recordInviteFromURL:"); + assertTrue(record < guard, "recording must not sit behind the store-id guard"); + } + + /// The url is somebody else's input and the code taken out of it is what + /// the application claims with, so the grammar is enforced where it is + /// known rather than trusted downstream. + @Test + void theCodeIsConstrainedBeforeItIsStored() throws Exception { + String src = text(files(), "CN1InviteClipDelegate.m"); + assertTrue(src.contains("cn1InviteCodeIsWellFormed"), "a grammar check exists"); + int check = src.indexOf("if (!cn1InviteCodeIsWellFormed(code)) { return; }"); + int store = src.indexOf("forKey:kHandoffKey"); + assertTrue(check > 0 && check < store, + "the check must precede the write, not follow it"); + } + + /// A display name is the developer's text and is interpolated into an + /// Objective-C string literal. A quotation mark in it would end the + /// literal and leave the rest as code. + @Test + void aQuotedDisplayNameCannotEscapeItsLiteral() { + assertEquals("Bob\\\"s \\\\ App", + InviteAppClipBuilder.escapeObjC("Bob\"s \\ App")); + assertEquals("one two", InviteAppClipBuilder.escapeObjC("one\ntwo")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index c356efd8c6d..0ccf7557451 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -220,6 +220,84 @@ void asurvivingOutboxIsNotDrainedUntilTheErasureFinishes() { "the erasure was never retried"); } + @FormTest + void asurvivingCodeIsNotClaimedUnderTheNewIdentity() { + // The retry gate lived only in drainOutbox(), and the lookup path had + // none. A PENDING record that outlived its erasure still carried the + // code a direct link left on the device, and the next checkForInvite() + // reloaded it and claimed it under the NEW client id -- which is the + // transmission the erasure existed to prevent, made by the erasure's + // own aftermath. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleUrl("https://cloud.codenameone.com/i/ABC123"); + implementation.clearQueuedRequests(); + + InviteStore.failNextDeleteForTest(InviteStore.PENDING); + assertFalse(Invites.eraseInternal(), "the fixture's erasure did not fail"); + // The record really did survive, or this test proves nothing about the + // gate: a deleted record cannot be claimed either way. + assertFalse(InviteStore.read(InviteStore.PENDING) == null + || InviteStore.read(InviteStore.PENDING).isEmpty(), + "the fixture did not leave a surviving record to claim"); + + // Storage is still refusing, so the retry inside the gate fails too and + // nothing may proceed. + InviteStore.failNextDeleteForTest(InviteStore.PENDING); + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "a code that survived an erasure was claimed under the new identity"); + } + + @FormTest + void afreshInviteIsNotAppendedToAQueueTheErasureWillDelete() { + // create() appended to whatever outbox was on the disk. An outbox that + // survived an erasure is deleted WHOLE by the retry inside the next + // drain -- which create() itself triggers through flush() -- so the + // invite just minted went with it. Having reported success, nothing + // held its code, and isRegistered() answered true about a registration + // the server was guaranteed never to have seen. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.create(InviteRequest.create().campaign("old").build()); + assertFalse(InviteStore.readOutbox().isEmpty(), "the fixture queued nothing"); + + InviteStore.failNextDeleteForTest(InviteStore.OUTBOX); + assertFalse(Invites.eraseInternal(), "the fixture's erasure did not fail"); + + // Storage recovers, which is the case the finding is about: the retry + // inside create() now succeeds, so the stale queue goes and the new + // invite is appended to a clean one rather than to a doomed one. + Invite fresh = Invites.create(InviteRequest.create().campaign("new").build()); + Invites.flush(); + + assertFalse(Invites.isRegistered(fresh), + "an invite that was never acknowledged reported itself registered"); + } + + @FormTest + void emptyingTheQueueIsVerifiedLikeEveryOtherDelete() { + // The last acknowledged registration empties the outbox, and that path + // called deleteStorageFile() and returned success without looking. + // On a port where the delete silently fails the entry stays durable, + // so every later flush resends an already acknowledged registration + // while isRegistered() goes on answering false about it. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.create(InviteRequest.create().campaign("launch").build()); + assertFalse(InviteStore.readOutbox().isEmpty(), "the fixture queued nothing"); + + InviteStore.failNextDeleteForTest(InviteStore.OUTBOX); + assertFalse(InviteStore.writeOutbox(new java.util.ArrayList()), + "emptying the queue reported success without verifying the delete"); + + // And the ordinary case still empties it and says so. + assertTrue(InviteStore.writeOutbox(new java.util.ArrayList()), + "emptying the queue failed when the store was willing"); + assertTrue(InviteStore.readOutbox().isEmpty(), "the queue survived"); + } + @FormTest void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); From 01321066418920089d61eb3fa0ea92133a701fc2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:08:24 +0300 Subject: [PATCH 44/70] Invites: a transient store failure is not an answer, and the clip is iOS only Two review findings, both real. fallBackToMatch(true) records referrerRetry for a Play failure that may be readable next launch -- the service was busy, the bind did not take, the service dropped before answering -- and then hands over to the App Clip handoff. On Android there is no clip, so control arrives at the settle path immediately, and that path consulted the retry flag nowhere. It wrote a permanent STATE_NONE_FOUND over a referrer that was there the whole time, and no later flush or relaunch could reach it. Making the disconnect callback answer at all, in the previous commit, is what made this reachable often rather than rarely. The exact "referrer read, no invite" answer stays final, which is the half that keeps an ordinary uninvited install from asking on every launch for ever. Both are pinned, and the fixture had to register a null App Clip source to model Android at all -- with the test harness's parked clip source in place the settle path is never reached and either assertion passes for the wrong reason. The App Clip target also needed the Catalyst guard every other iOS-only target here carries, and needed it more than most: an App Clip does not exist on the Mac, so an unfiltered dependency makes the Catalyst destination build a target whose product type is unsupported there and then embed it in the Mac app, failing the archive for a slice that could never have used it. Also the developer-guide prose gates: contractions, one British spelling and one "an invite" the style checks reject. --- .../codename1/analytics/invite/Invites.java | 38 +++++++ docs/developer-guide/Analytics.asciidoc | 8 +- .../codename1/build/shared/BuildHintsIos.java | 2 +- .../com/codename1/builders/IPhoneBuilder.java | 19 +++- .../invite/InviteResilienceTest.java | 103 ++++++++++++++++++ 5 files changed, 163 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index cdc69113d36..54e207e6b87 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2135,6 +2135,27 @@ private static void settleNoHandoff(String reason) { if (abandonReplacement()) { return; } + // A transient referrer failure is not an answer about this install. + // + // fallBackToMatch(true) records referrerRetry for exactly that case -- + // the Play service was busy, the bind did not take, the service + // dropped before it answered -- and then hands over to the clip + // handoff. On Android there is no clip, so control arrives here + // immediately and wrote a PERMANENT no-match over a referrer that was + // there the whole time and readable on the next launch. The retry + // marker was consulted on the server's answer and nowhere else, so + // this path discarded it. + // + // Silent, like the matching branch in handleResolution: + // attributionUnavailable() means no invite will ever be attributed, + // and this is the opposite of terminal. The attempt cap and the + // attribution window still bound how long it can go on. + Map outstanding = readPending(); + if (outstanding != null + && "true".equals(InviteStore.get(outstanding, "referrerRetry", null))) { + setState(STATE_PENDING); + return; + } if (markTerminal(reason)) { notifyUnavailable(reason); } @@ -2350,6 +2371,23 @@ static void handleResolution(String payload, String matchType, boolean deferred, } applySlug(payload); if (!truthy(json.get("resolved"))) { + if (truthy(json.get("retry"))) { + // "Not yet", not "no". The server has never seen this code + // at all, which during the offline-mint window is the + // normal state of a perfectly good invite: the inviter + // minted it with no network and their registration has not + // landed yet. Settling here reported an invited install as + // organic, permanently, seconds before the code became + // claimable. + // + // Silent for the same reason the referrer retry below is: + // attributionUnavailable() means no invite will ever be + // attributed, and this is the opposite of terminal. The + // existing attempt cap and attribution window bound how + // long this can go on. + setState(STATE_PENDING); + return; + } // Not terminal while a deterministic answer is still // reachable. The Play referrer failed transiently -- the store // was busy, the bind did not take -- and the source keeps its diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index 3c1f8d0d960..627cc0e4f73 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -244,14 +244,14 @@ Nothing at all is collected about someone who only taps a link. An earlier desig Referencing this package makes the build wire the platform side: an `autoVerify` App Links intent filter on Android, an associated domain on iOS, and the Play Install Referrer dependency. An app that only reports analytics gets none of it. -On iOS it also generates the App Clip, because there is no attribution without one. The clip is a separate binary embedded in your app: a few hundred lines of UIKit that show your app's name, offer to install it, and record the invite code the link handed them. It is not a Codename One application and does not run your code. You do not write it, open it or maintain it. +On iOS it also generates the App Clip, because there is no attribution without one. The clip is a separate binary embedded in your app: a few hundred lines of UIKit that show your app's name, offer to install it, and record the invite code the link handed them. It's not a Codename One application and doesn't run your code. You don't write it, open it or maintain it. Three things the generated clip needs from you, and each fails in its own way: -* The App Store identifier of your app, in `ios.invite.appStoreId`. Without it the clip still records the code, it simply shows no install sheet -- which is the right behaviour before your first release, when the app does not exist in the store yet. -* An App Group registered on your developer account. The build derives one from your package name and adds it to `ios.app_groups`; override it with `ios.invite.appGroup` if you already have one. A group that is not registered signs cleanly and the clip and the app can then never reach each other, which is the failure with no symptom. +* The App Store identifier of your app, in `ios.invite.appStoreId`. Without it the clip still records the code, it simply shows no install sheet -- which is the right behavior before your first release, when the app doesn't exist in the store yet. +* An App Group registered on your developer account. The build derives one from your package name and adds it to `ios.app_groups`; override it with `ios.invite.appGroup` if you already have one. A group that isn't registered signs cleanly and the clip and the app can then never reach each other, which is the failure with no symptom. * An App Clip enabled on your App ID, alongside Associated Domains. -* An Advanced App Clip Experience registered in App Store Connect for your invite link prefix. Codename One authorises your clip for the domain; what maps a particular link to a particular clip is that experience, and until it exists tapping an invite link shows no clip card at all. The console shows the prefix to register once invites are switched on. +* An Advanced App Clip Experience registered in App Store Connect for your invite link prefix. Codename One authorizes your clip for the domain; what maps a particular link to a particular clip is that experience, and until it exists tapping the invite link shows no clip card at all. The console shows the prefix to register once invites are switched on. Set `ios.invite.appClip` to `false` only if you ship an App Clip of your own. The app then reports every install as organic unless your clip writes the handoff itself. diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 1b0d4846d75..9c95f57b701 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -228,7 +228,7 @@ static void register(List h) { .platform("ios") .doc("Whether the build generates and embeds the App Clip that makes invite " + "attribution exact on iOS. The App Store carries no referrer of its " - + "own, so without the clip an iOS install cannot be attributed at all. " + + "own, so without the clip an iOS install can't be attributed at all. " + "Set it to `false` only if you ship an App Clip of your own; the build " + "then writes no clip, and the app reports every install as organic " + "unless your clip writes the handoff itself. Ignored when " diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index b3203a644d3..0964e39124c 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -12376,8 +12376,23 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, + "embed_phase.dst_subfolder_spec = \"16\"\n" + "embed_phase.dst_path = \"$(CONTENTS_FOLDER_PATH)/AppClips\"\n" + "embed_phase.run_only_for_deployment_postprocessing=\"0\"\n" - + "embed_phase.add_file_reference(fileref)\n" - + "clip_target.build_configurations.each{|e| \n"); + + "embed_file = embed_phase.add_file_reference(fileref)\n"); + if (macNativeBuilder.isEnabled()) { + // Same guard every other iOS-only target here carries, and this + // one needs it more than most: an App Clip does not exist on the + // Mac at all. Left unfiltered, the Catalyst destination builds a + // target whose whole product type is unsupported there and then + // tries to place it inside the Mac app, which fails the archive -- + // for a slice that could never have used it. The iOS app keeps its + // clip; the Mac slice ships without one, which costs nothing, + // because a Mac install was never attributed through a clip. + sb.append("dep = main_app_target.dependencies.find{|d| d.target" + + " && d.target.uuid == clip_target.uuid}\n" + + "dep.platform_filter = 'ios' if dep\n" + + "embed_file.platform_filter = 'ios'\n"); + buildSettingsMap.put("SUPPORTS_MACCATALYST", "NO"); + } + sb.append("clip_target.build_configurations.each{|e| \n"); for (String buildSettingKey : buildSettingsMap.keySet()) { sb.append(" e.build_settings['" + escapeRuby(buildSettingKey) + "'] = \"" + escapeRubyDoubleQuoted(buildSettingsMap.get(buildSettingKey)) + "\"\n"); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index f5ecc49ebb2..b4d8cf95e9c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -126,6 +126,40 @@ void aNoMatchAnswerIsNotAskedAgainOnTheNextLaunch() { "the terminal answer did not survive a relaunch"); } + @Test + @EdtTest + void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { + // The offline-mint window. An invite minted with no network is handed + // over before its registration reaches the server, so a claim can + // arrive first -- and the server has never heard of the code. Read as + // a final "no invite", that settled the install as organic + // permanently, seconds before the code became claimable, which loses + // exactly the attribution the offline mint exists to preserve. + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false,\"retry\":true}", + Invites.MATCH_APP_CLIP, true); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a not-yet answer was treated as a final no"); + + // And the real answer still lands when the registration catches up. + Invites.handleResolution(InviteTestSupport.resolvedJson("LATE1", "spring", "sms"), + Invites.MATCH_APP_CLIP, true); + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + assertNotNull(Invites.getAttribution(), "the late answer was refused"); + } + + @Test + @EdtTest + void aPlainNoIsStillFinalEvenBesideTheRetryAnswer() { + // The retry flag must not soften the ordinary case. Most installs are + // not invited, and an uninvited one that keeps asking contacts the + // server on every launch for ever. + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false,\"retry\":false}", + Invites.MATCH_APP_CLIP, true); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + } + @Test @EdtTest void theTerminalMarkerKeepsNoDeviceProfile() { @@ -475,6 +509,75 @@ public void attributionUnavailable(String reason) { "a refused direct link told the listener nothing"); } + @Test + @EdtTest + void aTransientReferrerFailureIsNotSettledAsOrganic() { + // The Play service was busy, the bind did not take, or it dropped + // before answering. None of those is an answer about this install, and + // the source keeps its once-only flag unset precisely so a later + // launch can read the exact referrer. + // + // On Android the retry marker was written and then ignored: there is + // no App Clip to fall through to, so control reached the settle path + // immediately and wrote a PERMANENT no-match over a referrer that was + // readable the whole time. + // Android, so there is no clip to fall through to -- which is the + // whole point: the settle path is reached immediately instead of + // parking on a handoff that would keep the lookup alive by itself. + Invites.registerAppClipHandoffSource(null); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + } + }); + Invites.checkForInvite(); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a transient store failure was settled as a final no"); + assertFalse(Invites.getState() == Invites.STATE_NONE_FOUND); + + // And the exact answer still lands when the store recovers. + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=LATER1", 0L, 0L); + } + }); + Invites.flush(); + Map pending = InviteStore.read(InviteStore.PENDING); + assertEquals("LATER1", InviteStore.get(pending, "code", null), + "the retried referrer was never read"); + } + + @Test + @EdtTest + void anExactReferrerAnswerOfNoInviteIsStillFinal() { + // The referrer was READ and carries no invite: a real answer, and a + // permanent one. The retry path must not swallow this case, or an + // ordinary uninvited install asks again on every launch for ever. + Invites.registerAppClipHandoffSource(null); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=google-play&utm_medium=organic", 0L, 0L); + } + }); + Invites.checkForInvite(); + + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "an exact 'no invite' answer was left pending"); + } + @Test @EdtTest void theReferrerCodeIsPersistedBeforeTheClaimGoesOut() { From e18bff55bbac569e0e663e9e99f4e1101a34ff52 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:23:42 +0300 Subject: [PATCH 45/70] Invites: the retry could not deliver, and the app group was comma-joined Three review findings, all real. The completion guard added in the previous commit was per INSTANCE, and Invites keeps one source and calls it again on a later flush. So a transient failure set `answered` for good: the retry then read an exact referrer, deliver() recorded PREF_ATTEMPTED, and referrer() suppressed the callback -- the code was read and thrown away, and no relaunch could ask for it again. The guard that was meant to stop a stale disconnect answering twice stopped the real answer arriving at all. It resets per exchange now, and a per-attempt sequence replaces the boolean that only covered the retry. Two different things supersede a listener and both had to be caught: the retry, whose close() fires the OLD listener's disconnect, and a later flush, which starts an exchange a lingering listener from the previous one would otherwise answer. The sequence advances BEFORE the retry's close, because ending a connection is what fires its own disconnect -- closing while the attempt is still current lets that disconnect answer "no referral" for a store that had not been asked yet. Not unit tested, and cannot be here: Ports/Android/.../referrer is excluded from the module build because it names com.android.installreferrer, so the whole class has no test coverage in this repo. Verified by compiling it against the platform jar and a stub of that API. The App Clip's app group was appended to ios.app_groups with a comma. The cloud builder's generateEntitlements splits that argument on " " alone, so an app that already had a group received one malformed identifier matching neither -- it signs, and then cannot open the container it shares with its own clip. An app with no other group never saw it, because there was nothing to join to. Space now, through declaresAppGroup, which compares entry by entry rather than by substring. Note the same comma appears in the Surfaces, Documents and Matter blocks here and is the same bug; it is left alone deliberately rather than swept into an invite change. And the public API contract still promised MATCH_FINGERPRINT and probabilistic attribution. Callers could branch on a constant that no longer exists, or discount an App Clip match that is exact. InviteAttribution now documents the three real match types and says plainly that all of them are exact; getConfidence() keeps answering 1 and says why it still exists. --- .../analytics/invite/InviteAttribution.java | 31 ++++-- .../codename1/analytics/invite/Invites.java | 7 +- .../referrer/AndroidInstallReferrer.java | 95 +++++++++++++------ .../com/codename1/builders/IPhoneBuilder.java | 33 ++++--- 4 files changed, 109 insertions(+), 57 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java index 1f9dce1eb64..995a895b217 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java @@ -29,12 +29,22 @@ /// The invite that caused this install or open. Immutable; delivered to an /// [InviteListener]. /// -/// Read [#getMatchType] before acting on this. A deterministic match came -/// through the store or from a code the user entered and is exact. A -/// [Invites#MATCH_FINGERPRINT] match is a statistical guess made on the -/// server, because the App Store carries no referrer of its own, and it is -/// occasionally wrong. Do not pay a referral bounty on a probabilistic match -/// without saying so. +/// [#getMatchType] says how the invite was identified, and every answer is +/// exact: +/// +/// - [Invites#MATCH_DIRECT] -- the link opened an app that was already +/// installed. +/// - [Invites#MATCH_REFERRER] -- the code travelled through Google Play and +/// came back verbatim. +/// - [Invites#MATCH_APP_CLIP] -- an iOS App Clip was launched by the invite +/// link, so it received the code exactly, and handed it to the app the +/// person then installed. +/// +/// Nothing here is matched, estimated or guessed, so a referral bounty can be +/// paid on any of them. An earlier design added a statistical match for iOS, +/// because the App Store carries no referrer of its own; App Clips made it +/// unnecessary and it is gone, along with the profile of the visitor it +/// needed. public final class InviteAttribution { private final String code; private final String campaign; @@ -104,7 +114,7 @@ public String getPayload() { } /// How this attribution was established: [Invites#MATCH_DIRECT], - /// [Invites#MATCH_REFERRER] or [Invites#MATCH_FINGERPRINT]. + /// [Invites#MATCH_REFERRER] or [Invites#MATCH_APP_CLIP]. /// /// #### Returns /// @@ -113,8 +123,11 @@ public String getMatchType() { return matchType; } - /// How much to trust this attribution, from 0 to 1. Both deterministic - /// match types report 1; a fingerprint match reports the server's score. + /// How much to trust this attribution, from 0 to 1. + /// + /// Always 1. Every match type is exact now, so there is nothing left for + /// this to discount -- it survives because an application that branched on + /// it should keep compiling and keep taking the same branch. /// /// #### Returns /// diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 54e207e6b87..83d2a189780 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -250,9 +250,8 @@ public final class Invites { /// A direct link was the worst case: `handleUrl` committed STATE_PENDING /// and issued the claim, and if that request also failed, the exact code /// existed nowhere. The retry then read a record with no code in it and - /// fell back to the install referrer or the fingerprint -- answering a - /// question the device already had an exact answer to, with a guess or not - /// at all. + /// fell back to the install referrer or the App Clip handoff -- asking a + /// question the device already had an exact answer to. /// /// Held only while the durable copy is missing: a successful write clears /// it, so this can never disagree with what is on the disk. It does not @@ -994,7 +993,7 @@ public static void flush() { } /// Forgets every trace of invite attribution on this device: the pending - /// fingerprint, the resolved attribution and the referral dimensions. + /// lookup, the resolved attribution and the referral dimensions. /// /// [Analytics#resetClientId] triggers this for you, because an erasure /// that left the referral dimensions behind would re-link the fresh diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index a8a437c0cd4..d158354f827 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -51,12 +51,28 @@ public class AndroidInstallReferrer implements InstallReferrerSource { private boolean retried; - // Whether the framework has been given its one answer. The SPI promises - // exactly one call, and the disconnect handler added below can arrive - // after a real answer as easily as instead of one -- ending a connection - // is itself what fires it. + // Whether the framework has been given its one answer FOR THIS EXCHANGE. + // The SPI promises exactly one call, and the disconnect handler below can + // arrive after a real answer as easily as instead of one -- ending a + // connection is itself what fires it. + // + // Reset by requestReferrer, and that reset is load bearing. Invites keeps + // one source instance and calls it again on a later flush; left set from a + // transient failure, this suppressed the retry's answer while deliver() + // had already recorded the read as attempted, so an exact code was read + // and thrown away and no relaunch could ask for it again. private boolean answered; + // Which attempt is current. Every bind captures this and answers only + // while it still matches. + // + // One counter rather than a flag per attempt, because two different things + // supersede a listener and both have to be caught: the retry below, whose + // close() fires the OLD listener's disconnect, and a later flush, which + // starts a whole new exchange that a lingering listener from the previous + // one would otherwise answer. + private int attemptSeq; + @Override public boolean isSupported() { return AndroidNativeUtil.getContext() != null @@ -65,9 +81,19 @@ public boolean isSupported() { @Override public void requestReferrer(InstallReferrerCallback callback) { + // A NEW exchange, so both guards start clean. The internal retry does + // not come through here -- it calls attempt() directly -- because + // resetting `retried` there would turn one allowance into a loop. + answered = false; + retried = false; + attempt(callback); + } + + private void attempt(InstallReferrerCallback callback) { + attemptSeq++; Context context = AndroidNativeUtil.getContext(); if (context == null) { - unavailable(callback, Invites.REASON_UNSUPPORTED); + unavailable(attemptSeq, callback, Invites.REASON_UNSUPPORTED); return; } try { @@ -77,23 +103,25 @@ public void requestReferrer(InstallReferrerCallback callback) { // never as a crash: the application still works, it simply has no // invite behind it. Log.e(t); - finish(callback, Invites.REASON_UNSUPPORTED); + finish(attemptSeq, callback, Invites.REASON_UNSUPPORTED); } } private void connect(final InstallReferrerClient client, final InstallReferrerCallback callback) { - // Per ATTEMPT, not per instance. The retry below ends this connection, - // which fires this listener's own disconnect -- and that must not be - // read as the retried connection failing. - final boolean[] superseded = new boolean[1]; + // Captured, not read at callback time. The retry below ends this + // connection, which fires this listener's own disconnect, and a later + // flush starts a whole new exchange -- a listener that read the field + // when it fired would inherit whichever attempt is current and answer + // for it. + final int issued = attemptSeq; client.startConnection(new InstallReferrerStateListener() { @Override public void onInstallReferrerSetupFinished(int responseCode) { try { switch (responseCode) { case InstallReferrerClient.InstallReferrerResponse.OK: - deliver(client, callback); + deliver(issued, client, callback); break; case InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE: // Transient. Exactly one retry: a loop here would @@ -101,9 +129,18 @@ public void onInstallReferrerSetupFinished(int responseCode) { // never going to answer. if (!retried) { retried = true; - superseded[0] = true; + // The sequence advances BEFORE the close, and + // that order is the whole guard. Ending a + // connection is what fires its own listener's + // disconnect, so closing while this attempt is + // still current lets that disconnect answer + // the exchange the retry was about to make -- + // with "no referral", for a store that had not + // been asked yet. attempt() advances it again, + // which only skips a number. + attemptSeq++; close(client); - requestReferrer(callback); + attempt(callback); return; } // Transient, so it is NOT recorded as attempted. @@ -113,7 +150,7 @@ public void onInstallReferrerSetupFinished(int responseCode) { // skip the deterministic path and fall back to a // statistical guess for a referrer we could have // read exactly. - unavailable(callback, Invites.REASON_NO_MATCH); + unavailable(issued, callback, Invites.REASON_NO_MATCH); break; default: // FEATURE_NOT_SUPPORTED is the ordinary answer on a @@ -122,14 +159,14 @@ public void onInstallReferrerSetupFinished(int responseCode) { // store. Terminal: this device will never have a // referrer, so the flag is recorded and the bind is // not attempted again. - finish(callback, Invites.REASON_UNSUPPORTED); + finish(issued, callback, Invites.REASON_UNSUPPORTED); break; } } catch (Throwable t) { // Unknown failure: treated as transient, so a later flush // can still read a referrer that is genuinely there. Log.e(t); - unavailable(callback, Invites.REASON_NO_MATCH); + unavailable(issued, callback, Invites.REASON_NO_MATCH); } finally { close(client); } @@ -151,15 +188,13 @@ public void onInstallReferrerServiceDisconnected() { // Reported as transient, which is what it is -- the once-only // flag stays unburnt, so a later flush can still read a // referrer that was there the whole time. - if (superseded[0]) { - return; - } - unavailable(callback, Invites.REASON_NO_MATCH); + unavailable(issued, callback, Invites.REASON_NO_MATCH); } }); } - private void deliver(InstallReferrerClient client, InstallReferrerCallback callback) { + private void deliver(int issued, InstallReferrerClient client, + InstallReferrerCallback callback) { String referrer = ""; long clickSeconds = 0; long beginSeconds = 0; @@ -186,20 +221,20 @@ private void deliver(InstallReferrerClient client, InstallReferrerCallback callb // statistical no-match settles the install as organic -- for a // referrer that was there all along and simply could not be read // this once. - unavailable(callback, Invites.REASON_NO_MATCH); + unavailable(issued, callback, Invites.REASON_NO_MATCH); return; } Preferences.set(PREF_ATTEMPTED, true); if (referrer == null || referrer.length() == 0) { - unavailable(callback, Invites.REASON_NO_MATCH); + unavailable(issued, callback, Invites.REASON_NO_MATCH); return; } - referrer(callback, referrer, clickSeconds, beginSeconds); + referrer(issued, callback, referrer, clickSeconds, beginSeconds); } - private void finish(InstallReferrerCallback callback, String reason) { + private void finish(int issued, InstallReferrerCallback callback, String reason) { Preferences.set(PREF_ATTEMPTED, true); - unavailable(callback, reason); + unavailable(issued, callback, reason); } /// Reports "no referral", at most once. @@ -207,17 +242,17 @@ private void finish(InstallReferrerCallback callback, String reason) { /// Every terminal path goes through here so the disconnect handler can /// close an exchange nobody else closed without risking a second answer /// for one that somebody did. - private void unavailable(InstallReferrerCallback callback, String reason) { - if (answered) { + private void unavailable(int issued, InstallReferrerCallback callback, String reason) { + if (answered || issued != attemptSeq) { return; } answered = true; callback.onUnavailable(reason); } - private void referrer(InstallReferrerCallback callback, String value, + private void referrer(int issued, InstallReferrerCallback callback, String value, long clickSeconds, long beginSeconds) { - if (answered) { + if (answered || issued != attemptSeq) { return; } answered = true; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 0964e39124c..bed334f6de2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4485,23 +4485,28 @@ public void usesClassMethod(String cls, String method) { // which needed it to decide whether to register a reader at // all. Re-deriving it here would let the two disagree. String group = inviteAppClipGroup; - // Entry by entry, never a substring test: group.com.acme.shared - // contains group.com.acme, and deciding the group is already - // present on that basis entitles the clip for one group and the - // application for another -- two processes that sign, install, - // and never meet. + // SPACE, not a comma, and read through declaresAppGroup. + // + // generateEntitlements splits ios.app_groups on " " alone, so + // a comma-joined pair reaches the device as a single + // "group.a,group.b", which matches neither configured group. + // The app then signs and cannot open the container it shares + // with its own clip, which + // is this feature failing with no error anywhere. An app with + // no other app group never saw it, because there was nothing + // to join to. + // + // declaresAppGroup compares entry by entry and tolerates + // either separator when reading, which is both what makes this + // safe against a hand-written comma list and what hid the bug: + // group.com.acme.shared contains group.com.acme, and a + // substring test would decide the group was already present + // and entitle the clip for one group and the app for another. String appGroups = request.getArg("ios.app_groups", ""); - boolean present = false; - for (String candidate : appGroups.split(",")) { - if (candidate.trim().equals(group)) { - present = true; - break; - } - } - if (!present) { + if (!declaresAppGroup(appGroups, group)) { request.putArgument("ios.app_groups", appGroups.trim().length() == 0 ? group - : appGroups.trim() + "," + group); + : appGroups.trim() + " " + group); } try { replaceInFile(new File(buildinRes, From d2383285ea1369d16536dab1a28fc30edd0bc052 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:09:54 +0300 Subject: [PATCH 46/70] Invites: a reset that could not finish, and a tap time nobody kept Two review findings, plus two defects of my own found while verifying them. reset() threw away resetVerified()'s answer. The detection added last round was real and then dropped at the one call site an application reaches, so a PENDING record that outlived its erasure was still claimed on the next launch and a surviving outbox entry still went out under the old client id. It latches the gate now -- but only when something really did survive. resetVerified() also answers false for a reason that leaves nothing behind, no Storage at all, and latching on that would block a device with no invite data to block over: nothing else proceeds until an erasure succeeds, and that is a severe consequence to hang on a device state. The mirror image was also wrong, and was mine from the previous commit: only eraseInternal() ever cleared the flag, so a plain reset() that SUCCEEDED left a stale latch standing, and the next gated call then ran a full erasure -- tombstone included -- turning an ordinary reset() into a terminal state the application never asked for. A successful reset clears it, which is what the flag has always claimed to mean. The tap time is kept now. An App Clip invocation is resolved by iOS from the association file, so it never reaches our redirect: the clip is the only witness, and the native side clears the handoff as it reads it. Dropped in the callback it was gone, and getClickTimestamp() answered zero for every App Clip attribution. It is persisted in the pending record -- the claim can fail and be resent from there -- and sent as clickedMillis. The Play referrer had the identical bug and the finding did not mention it: onReferrer carries clickSeconds and nothing read it either. Fixed together, because fixing one platform and not the other leaves the two reporting the same field differently, which is worse than both being wrong. Asserted as a bare JSON number rather than a quoted one, because the server binds it to a long: a string coerces today and stops the moment anything there gets stricter. And the PMD gate caught five violations I had pushed: PATH_MATCH left behind by the deleted /match endpoint, and four missing @Override on the App Clip callback, which the install-referrer callback directly above it has. I had been skipping the static-analysis gates locally because SpotBugs will not run under this JDK; PMD does, through pmd:pmd, and it is clean now -- verified by injecting an unused field and confirming it is reported, because a report that is empty because nothing was analysed looks exactly like one that is empty because nothing is wrong. --- .../analytics/invite/InviteAttribution.java | 13 +- .../codename1/analytics/invite/Invites.java | 119 +++++++++++++++++- .../analytics/invite/InviteDeliveryTest.java | 36 ++++++ .../analytics/invite/InviteTestSupport.java | 7 +- 4 files changed, 166 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java index 995a895b217..e620a1aa5e1 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java @@ -146,12 +146,19 @@ public boolean isDeferred() { return deferred; } - /// When the link was clicked, in milliseconds since the epoch, or 0 when - /// unknown. + /// When the link was tapped, in milliseconds since the epoch, or 0 when + /// nothing observed it. + /// + /// Zero is a real answer and not rare. The tap is observed by whichever + /// side of the exchange saw it: the invite redirect for a link that + /// reached it, or the App Clip for an iOS invocation, which iOS resolves + /// from the association file without ever reaching the redirect. An + /// install whose tap neither side recorded has no time to report, so + /// compare against 0 before subtracting it from anything. /// /// #### Returns /// - /// the click time + /// the click time, or 0 public long getClickTimestamp() { return clickTimestamp; } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 83d2a189780..20674396ee7 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -186,7 +186,6 @@ public final class Invites { private static final String DEFAULT_BASE_URL = "https://cloud.codenameone.com"; private static final String PATH_MINT = "/api/v2/analytics/invites"; private static final String PATH_CLAIM = "/api/v2/analytics/invites/claim"; - private static final String PATH_MATCH = "/api/v2/analytics/invites/match"; // Package private so the unit tests can clear them between cases. /// Display property carrying the invite host the build registered, stamped @@ -999,7 +998,58 @@ public static void flush() { /// that left the referral dimensions behind would re-link the fresh /// identity to the same inviter. public static void reset() { - resetVerified(); + if (!resetVerified()) { + // The records did not go, and this method promised they would. + // + // Dropping the answer here left nothing blocked and nothing + // retrying: a surviving PENDING record still carried its code, so + // the next checkForInvite() claimed it, and a surviving outbox + // entry still carried the old client id for the next flush. The + // detection added inside resetVerified() was real and then thrown + // away at the one call site an application reaches. + // + // Setting the flag is what settleErasure() gates every lookup, + // claim and enqueue on, so nothing proceeds until a retry + // succeeds. That retry runs eraseInternal(), which also writes the + // tombstone -- so a reset that had to be retried ends terminal + // rather than looking like a fresh install. That divergence is + // deliberate: it only happens when the store refused, and there + // the safe answer is to attribute nothing rather than to start a + // fresh lookup over records that are still on the disk. + // + // Latched only when something really did survive, because the + // consequence is severe and permanent-looking: nothing else + // proceeds until an erasure succeeds, and only eraseInternal() + // clears the flag. resetVerified() also answers false for a + // reason that leaves nothing behind -- no Storage at all, which + // is a device state rather than a refusal -- and latching on that + // would block a device that has no invite data to block over. + if (anythingSurvives()) { + erasurePending = true; + } + } + } + + /// Whether any durable invite record is still readable. + /// + /// The question a failed reset actually has to answer. A delete that could + /// not run because there was no storage at all leaves nothing behind and + /// is not the failure the erasure gate exists for; a record still on the + /// disk is. + /// + /// #### Returns + /// + /// true when a record, an attribution or a queued registration remains + private static boolean anythingSurvives() { + Map record = InviteStore.read(InviteStore.PENDING); + if (record != null && !record.isEmpty()) { + return true; + } + Map attribution = InviteStore.read(InviteStore.ATTRIBUTION); + if (attribution != null && !attribution.isEmpty()) { + return true; + } + return !InviteStore.readOutbox().isEmpty(); } /// The same work, reporting whether the durable records really went. @@ -1047,6 +1097,15 @@ static boolean resetVerified() { lookupIssuedAt = 0; undelivered = null; unacknowledged.clear(); + if (cleared) { + // The flag means "records survived an erasure", and they + // demonstrably did not survive this one. Only eraseInternal() + // cleared it before, so a plain reset() that succeeded left the + // stale latch standing -- and the next gated call then ran a full + // erasure, tombstone included, turning an application's ordinary + // reset() into a terminal state it never asked for. + erasurePending = false; + } return cleared; } @@ -1868,7 +1927,8 @@ private static void beginDeferred() { String matchType = InviteStore.get(pending, "codeMatch", MATCH_DIRECT); boolean deferred = InviteStore.getBoolean(pending, "codeDeferred", false); claim(code, source, InviteStore.get(pending, "codeReferrer", ""), - matchType, deferred); + matchType, deferred, + InviteStore.getLong(pending, "codeClicked", 0)); return; } InstallReferrerSource source = referrerSource; @@ -1938,11 +1998,25 @@ public void run() { pending.put("codeDeferred", "true"); InviteStore.put(pending, "codeReferrer", rawReferrer == null ? "" : rawReferrer); + // Play reports the tap time too, and it was being + // dropped for the same reason the clip's was: read + // from the callback and never written down. The + // redirect DID see this tap, so the server usually + // has its own record -- but not for a link opened + // from a place the redirect never ran, and not + // after retention has swept the click. Carrying it + // costs nothing and makes the two platforms report + // the same field the same way. + if (clickSeconds > 0) { + pending.put("codeClicked", + String.valueOf(clickSeconds * 1000L)); + } pending.remove("referrerRetry"); writePending(pending); claim(code, "install_referrer", rawReferrer == null ? "" : rawReferrer, - MATCH_REFERRER, true); + MATCH_REFERRER, true, + clickSeconds > 0 ? clickSeconds * 1000L : 0); } }); } @@ -2079,8 +2153,10 @@ private static void requestAppClipHandoff(final Map pending) { // this answer belongs to. final int issued = lookupEpoch; source.requestHandoff(new AppClipHandoffCallback() { + @Override public void onHandoff(final String code, final long clickedSeconds) { onEdt(new Runnable() { + @Override public void run() { if (issued != lookupEpoch) { return; @@ -2106,17 +2182,36 @@ public void run() { record.put("codeMatch", MATCH_APP_CLIP); record.put("codeDeferred", "true"); record.put("codeReferrer", ""); + // The tap time, and this is the only place it exists. + // + // An App Clip invocation is resolved by iOS from the + // association file, so it never reaches our redirect + // and the server has no click of its own to date the + // funnel from. The clip observed the tap and the + // native side cleared the handoff as it read it, so a + // value dropped here is gone -- and every App Clip + // attribution reported a click time of zero. + // + // Persisted in the record rather than only passed on, + // because the claim can fail and be resent from here. + if (clickedSeconds > 0) { + record.put("codeClicked", + String.valueOf(clickedSeconds * 1000L)); + } writePending(record); // Claimed exactly as a referrer code is: the trip // through the store is what makes both of them exact, // and the server treats them the same way. - claim(code, "app_clip", "", MATCH_APP_CLIP, true); + claim(code, "app_clip", "", MATCH_APP_CLIP, true, + clickedSeconds > 0 ? clickedSeconds * 1000L : 0); } }); } + @Override public void onUnavailable(final String reason) { onEdt(new Runnable() { + @Override public void run() { if (issued != lookupEpoch) { return; @@ -2162,6 +2257,17 @@ private static void settleNoHandoff(String reason) { private static void claim(String code, String source, String rawReferrer, final String matchType, final boolean deferred) { + claim(code, source, rawReferrer, matchType, deferred, 0); + } + + /// clickedMillis: when the link was tapped, as the device observed it, or + /// 0 when nothing on the device saw it. Only an App Clip has this: iOS + /// resolves a clip invocation from the association file, so that tap never + /// reaches the redirect and the server has no click to date the funnel + /// from. It is a hint, never an override -- the server prefers its own + /// observation, because this one is a number an app could put anything in. + private static void claim(String code, String source, String rawReferrer, + final String matchType, final boolean deferred, long clickedMillis) { if (!allowed()) { return; } @@ -2173,6 +2279,9 @@ private static void claim(String code, String source, String rawReferrer, body.put("code", code); body.put("source", source); body.put("rawReferrer", rawReferrer == null ? "" : rawReferrer); + if (clickedMillis > 0) { + body.put("clickedMillis", Long.valueOf(clickedMillis)); + } lookupIssuedAt = System.currentTimeMillis(); post(getLinkBase() + PATH_CLAIM, body, matchType, deferred); } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index 9798c800862..4137a8c2814 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -33,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class InviteDeliveryTest extends UITestBase { @@ -271,6 +272,41 @@ void aclipCodeIsWrittenDownBeforeItIsSent() { "the saved code lost its provenance"); } + @FormTest + void theclipsTapTimeSurvivesIntoTheClaim() { + // The clip is the only witness to the tap: iOS resolves a clip + // invocation from the association file, so it never reaches the + // redirect, and the native side clears the handoff as it reads it. + // Dropped here the value is gone, and every App Clip attribution + // reports a click time of zero. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + implementation.clearQueuedRequests(); + + long tappedSeconds = System.currentTimeMillis() / 1000L - 600L; + Invites.checkForInvite(); + InviteTestSupport.pendingHandoff.answer("CLIPTIME", tappedSeconds); + + // Written down, because the claim can fail and be resent from the + // record rather than from the callback. + Map record = InviteStore.read(InviteStore.PENDING); + assertNotNull(record); + assertEquals(tappedSeconds * 1000L, + InviteStore.getLong(record, "codeClicked", 0), + "the tap time was not persisted, so a resent claim loses it"); + + // And it is on the wire, in milliseconds. + List sent = implementation.getQueuedRequests(); + assertFalse(sent.isEmpty(), "no claim was sent at all"); + String body = ((com.codename1.io.ConnectionRequest) + sent.get(sent.size() - 1)).getRequestBody(); + // A bare number, not a quoted one. Asserted because the server binds + // it to a long: a string would still coerce today and would stop + // doing so the moment anything there gets stricter. + assertTrue(body.contains("\"clickedMillis\": " + (tappedSeconds * 1000L)), + "the claim did not carry the tap time as a number: " + body); + } + @FormTest void aclipAnswerThatOutlivedItsLookupIsIgnored() { // The read is asynchronous and everything that supersedes a lookup diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index aeb105dcb44..1e7ec369a5d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -62,10 +62,15 @@ boolean wasAsked() { /** Delivers a code, as a clip that saw the link would. */ void answer(String code) { + answer(code, 0L); + } + + /** Delivers a code with the tap time the clip observed. */ + void answer(String code, long clickedSeconds) { AppClipHandoffCallback cb = callback; callback = null; if (cb != null) { - cb.onHandoff(code, 0L); + cb.onHandoff(code, clickedSeconds); } } From b935910518687853327a7ca7efe9867dff704ea3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:19:40 +0300 Subject: [PATCH 47/70] Analytics: an erasure that never reached the disk came back a launch later resetClientId() clears the reserved cn1_ dimensions, and Preferences.set discards its own write-failure boolean -- the same trap Continuity documents and the reason Invites uses Storage instead. So on a full or read-only store the entries vanished from the in-memory map and stayed in the file, and the next launch loaded them back and attached the referral identity the user asked to be rid of to their NEW client id. One launch later, with nothing in memory left to notice. Two halves, because each closes a case the other cannot. persistDimensions() reports now, by reading the value back rather than trusting the write, and clearReservedDimensions() retries once on failure. That closes it inside the process, where the cause is usually transient. Across a restart no in-memory retry survives, so the persisted blob is stamped with the client id it was written under. loadDimensions() drops reserved entries whose stamp names an identity that has since been reset -- the erasure finishing late -- and keeps the APPLICATION's own dimensions, because those 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. An absent stamp reads as current, so a file written before this existed is not discarded. Both directions are pinned: a stamp from an erased identity drops only the cn1_ entries, and a stamp from the CURRENT identity keeps them -- without that second test the drop could be keyed on the prefix alone and throw the referral away on every ordinary launch. The first was revert-probed: with the check disabled the test reports the erased campaign coming back as "spring", which is the bug exactly. --- .../com/codename1/analytics/Analytics.java | 99 ++++++++++++++++--- .../analytics/AnalyticsFacadeTest.java | 44 +++++++++ 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index ccb45770a32..4e840f64aa3 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -71,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 PROVIDERS = new ArrayList(); // App-scoped segmentation dimensions ("plan", "role", ...) that the cloud @@ -527,13 +538,28 @@ 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() { + private static boolean clearReservedDimensions() { loadDimensions(); boolean changed = false; Iterator> it = DIMENSIONS.entrySet().iterator(); @@ -545,9 +571,20 @@ private static void clearReservedDimensions() { changed = true; } } - if (changed) { - persistDimensions(); + if (!changed) { + return true; + } + if (persistDimensions()) { + return true; } + // One retry, because the common cause is transient. If it still will + // not land, the entries are gone from memory and still in the file -- + // and the stamp written beside them now names the NEW client id's + // predecessor, so loadDimensions() drops them on the next launch + // rather than attaching them to the fresh identity. + Log.p("analytics: the reserved dimensions could not be erased from storage; " + + "they will be dropped on the next launch instead", Log.WARNING); + return persistDimensions(); } // Must be called while holding LOCK. Lazily loads the persisted dimensions @@ -563,6 +600,14 @@ 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 identity 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 treated as current, so a + // file written before this existed is not discarded. + String owner = Preferences.get(PREF_DIMENSIONS_OWNER, null); + boolean foreign = owner != null && clientId != null && !owner.equals(clientId); String[] rows = split(stored, '\n'); for (String row : rows) { if (row.length() == 0) { @@ -574,18 +619,44 @@ 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)) { + // 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) { + // Rewritten under the current identity so the drop happens once. + // If this write fails too the next launch simply repeats it, which + // is the correct outcome either way. + persistDimensions(); } } // Must be called while holding LOCK. - private static void persistDimensions() { - if (DIMENSIONS.isEmpty()) { - Preferences.set(PREF_DIMENSIONS, ""); - return; - } + /// Writes the dimensions and says whether the write really landed. + /// + /// `Preferences.set` returns nothing and swallows its own failure, so a + /// full or read-only store looked exactly like a successful write. The + /// value is read back instead of trusted, because for an erasure the + /// difference is the whole operation: entries removed only from the + /// in-memory map come back on the next launch. + /// + /// #### Returns + /// + /// true when the stored value matches what was written + private static boolean persistDimensions() { StringBuilder b = new StringBuilder(); boolean first = true; for (Map.Entry e : DIMENSIONS.entrySet()) { @@ -595,7 +666,13 @@ private static void persistDimensions() { b.append(sanitize(e.getKey())).append('\t').append(sanitize(e.getValue())); first = false; } - Preferences.set(PREF_DIMENSIONS, b.toString()); + String value = b.toString(); + Preferences.set(PREF_DIMENSIONS, value); + // Stamped with the identity these dimensions belong to, so a restart + // can tell a surviving file from a current one even when the write + // above failed and nothing in memory remembers. + Preferences.set(PREF_DIMENSIONS_OWNER, clientId == null ? "" : clientId); + return value.equals(Preferences.get(PREF_DIMENSIONS, null)); } // Replaces the delimiter characters so the persisted form parses back diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java index e133907ea06..8885d532f78 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java @@ -61,6 +61,50 @@ void clientIdIsStableAndResettable() { assertEquals(reset, Analytics.clientId()); } + @FormTest + void reservedDimensionsThatOutlivedAnErasureAreDroppedOnTheNextLaunch() { + // Preferences.set discards its 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 referral identity the user asked to be rid of to + // their NEW client id -- one launch later, with nothing in memory left + // to notice. + Analytics.clearProviders(); + Analytics.clearDimensions(); + String current = Analytics.clientId(); + + // The file as a failed erasure leaves it: framework dimensions and an + // application dimension, stamped with the identity that has gone. + Analytics.simulateSurvivingDimensionsForTest( + "cn1_campaign\tspring\ncn1_invite\tinstall_confirmed\nplan\tpro", + "an-erased-client-id"); + + Map loaded = Analytics.getDimensions(); + assertNull(loaded.get("cn1_campaign"), + "an erased referral came back and attached itself to the new client id"); + assertNull(loaded.get("cn1_invite")); + // The APPLICATION's own dimension is not what an erasure asked about, + // and losing it would be a second bug in the name of fixing the first. + assertEquals("pro", loaded.get("plan"), + "the application's own dimension was destroyed by someone else's erasure"); + assertEquals(current, Analytics.clientId(), "the fixture changed the identity"); + } + + @FormTest + void dimensionsFromTheCurrentIdentityAreKept() { + // The drop is keyed on the STAMP, not on the prefix, or an ordinary + // launch would throw away the referral dimensions every time. + Analytics.clearProviders(); + Analytics.clearDimensions(); + Analytics.simulateSurvivingDimensionsForTest( + "cn1_campaign\tspring\nplan\tpro", Analytics.clientId()); + + Map loaded = Analytics.getDimensions(); + assertEquals("spring", loaded.get("cn1_campaign"), + "a live referral was discarded on an ordinary launch"); + assertEquals("pro", loaded.get("plan")); + } + @FormTest void setUserIdRequiresPersonalizationConsent() { Analytics.clearProviders(); From 51173b4bc51bc7e7e00c7d1a5c1254d70222b94d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:22:56 +0300 Subject: [PATCH 48/70] Analytics: the header gate covers a file I modified AnalyticsFacadeTest had no copyright header, and the gate checks every file a change TOUCHES rather than only the ones it adds -- so editing it made the missing header this branch's problem. It carries the Codename One GPLv2 + Classpath header now, the same one every other file in this package has. Mine to have caught before pushing: I ran the header gate earlier in the branch and did not re-run it after the commit that touched this file, which is exactly the case it exists for. --- .../analytics/AnalyticsFacadeTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java index 8885d532f78..32c91f13de0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java @@ -1,3 +1,25 @@ +/* + * 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; import com.codename1.io.Preferences; From e2d3dc661bbff6a5d57e1ad195de21bd4a79e82c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:37:05 +0300 Subject: [PATCH 49/70] Invites: the write check I added checked nothing, and two iOS build traps The verification from the previous commit was worthless and I should have read Preferences before writing it. Preferences.set updates a static Hashtable and Preferences.get reads that same Hashtable, so comparing a value with what comes back compares memory with memory: it reports success for a write that never reached the disk. It was worse than useless. persistDimensions() stamped the file with the NEW client id in the same breath, so a failed erasure left the OLD reserved dimensions sitting under a stamp that claimed them as current -- and the stamp, which is the mechanism that was supposed to catch exactly this, said they belonged. The fix made the bug harder to see. The check is gone, with a note saying why it cannot work, and the stamp now carries the whole job. An ABSENT stamp counts as foreign rather than current, which is the difference between a mechanism that works and one that works only when the write it depends on succeeded: on a device whose file predates the stamp, or where the same storage failure that broke the erasure also stopped the stamp landing, there is nothing to compare. The trade is explicit -- a reserved dimension dropped wrongly is rewritten by the next attribution; an erased identity coming back is not recoverable -- and clientId() is used rather than the field, because loading can happen before the id is materialised and a null made every file look current. ios.invite.appClip=false disabled the receiving side too. It means "do not GENERATE a clip", which is what a developer sets when they ship one of their own -- and it was suppressing the app group, the native define and the registration of IOSAppClipHandoff along with it, so a custom clip wrote the documented handoff into the documented container and nothing read it. Generation and reception are separate questions now, and the hint's documentation said the wrong thing too. The clip also hard-coded TARGETED_DEVICE_FAMILY=1, on the belief that App Clips do not run on iPad. They do -- and an ios.project_type=ipad build has an iPad-only app target, so an iPhone-only clip inside it shares no family with its container and App Store validation rejects the archive. It uses the same host-family helper every other embedded target here uses. --- .../com/codename1/analytics/Analytics.java | 79 ++++++++++--------- docs/developer-guide/Analytics.asciidoc | 2 +- .../codename1/build/shared/BuildHintsIos.java | 7 +- .../com/codename1/builders/IPhoneBuilder.java | 41 +++++++--- .../util/InviteAppClipBuilderTest.java | 17 ++++ .../analytics/AnalyticsFacadeTest.java | 19 +++++ 6 files changed, 115 insertions(+), 50 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index 4e840f64aa3..0807b1ab887 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -559,7 +559,7 @@ static void simulateSurvivingDimensionsForTest(String raw, String owner) { public static final String RESERVED_DIMENSION_PREFIX = "cn1_"; // Must be called while holding LOCK. - private static boolean clearReservedDimensions() { + private static void clearReservedDimensions() { loadDimensions(); boolean changed = false; Iterator> it = DIMENSIONS.entrySet().iterator(); @@ -571,20 +571,9 @@ private static boolean clearReservedDimensions() { changed = true; } } - if (!changed) { - return true; - } - if (persistDimensions()) { - return true; + if (changed) { + persistDimensions(); } - // One retry, because the common cause is transient. If it still will - // not land, the entries are gone from memory and still in the file -- - // and the stamp written beside them now names the NEW client id's - // predecessor, so loadDimensions() drops them on the next launch - // rather than attaching them to the fresh identity. - Log.p("analytics: the reserved dimensions could not be erased from storage; " - + "they will be dropped on the next launch instead", Log.WARNING); - return persistDimensions(); } // Must be called while holding LOCK. Lazily loads the persisted dimensions @@ -602,12 +591,28 @@ private static void loadDimensions() { } // 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 identity 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 treated as current, so a - // file written before this existed is not discarded. + // 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 counts as foreign, not as current. That is the + // difference between a mechanism that works and one that works only + // when the write it depends on succeeded: on a device whose file + // predates the stamp -- or where the storage failure that broke the + // erasure also stopped the stamp being written -- there is nothing to + // compare, and treating that as current is exactly the case being + // defended against. Unknown provenance for a dimension the FRAMEWORK + // owns resolves to dropping it. + // + // The cost of being wrong that way is one re-resolution: a reserved + // dimension dropped here is rewritten by the next attribution. The + // cost of being wrong the other way is an erased identity coming back. + // + // clientId() rather than the field, because loading can happen before + // the id has been materialised and a null would make every file look + // current. It does not read dimensions, so there is no recursion. String owner = Preferences.get(PREF_DIMENSIONS_OWNER, null); - boolean foreign = owner != null && clientId != null && !owner.equals(clientId); + boolean foreign = !clientId().equals(owner); String[] rows = split(stored, '\n'); for (String row : rows) { if (row.length() == 0) { @@ -645,18 +650,19 @@ private static void loadDimensions() { } // Must be called while holding LOCK. - /// Writes the dimensions and says whether the write really landed. - /// - /// `Preferences.set` returns nothing and swallows its own failure, so a - /// full or read-only store looked exactly like a successful write. The - /// value is read back instead of trusted, because for an erasure the - /// difference is the whole operation: entries removed only from the - /// in-memory map come back on the next launch. - /// - /// #### Returns - /// - /// true when the stored value matches what was written - private static boolean persistDimensions() { + /// 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() { StringBuilder b = new StringBuilder(); boolean first = true; for (Map.Entry e : DIMENSIONS.entrySet()) { @@ -666,13 +672,12 @@ private static boolean persistDimensions() { b.append(sanitize(e.getKey())).append('\t').append(sanitize(e.getValue())); first = false; } - String value = b.toString(); - Preferences.set(PREF_DIMENSIONS, value); - // Stamped with the identity these dimensions belong to, so a restart - // can tell a surviving file from a current one even when the write - // above failed and nothing in memory remembers. + Preferences.set(PREF_DIMENSIONS, b.toString()); + // Stamped with the identity these dimensions belong to. This is what + // makes a surviving file distinguishable from a current one after a + // restart, when nothing in memory remembers that an erasure was asked + // for. Preferences.set(PREF_DIMENSIONS_OWNER, clientId == null ? "" : clientId); - return value.equals(Preferences.get(PREF_DIMENSIONS, null)); } // Replaces the delimiter characters so the persisted form parses back diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index 627cc0e4f73..d29dff94c2a 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -253,7 +253,7 @@ Three things the generated clip needs from you, and each fails in its own way: * An App Clip enabled on your App ID, alongside Associated Domains. * An Advanced App Clip Experience registered in App Store Connect for your invite link prefix. Codename One authorizes your clip for the domain; what maps a particular link to a particular clip is that experience, and until it exists tapping the invite link shows no clip card at all. The console shows the prefix to register once invites are switched on. -Set `ios.invite.appClip` to `false` only if you ship an App Clip of your own. The app then reports every install as organic unless your clip writes the handoff itself. +Set `ios.invite.appClip` to `false` only if you ship an App Clip of your own. The build then generates no clip, but your app still gets the shared App Group and the reader, so a clip of yours that writes the handoff is still picked up. WARNING: On Android, App Links verification checks the certificate the installed APK is signed with. Under Play App Signing that's Google's key, not your upload key, so add the app-signing SHA-256 from the Play Console to `android.invite.signingFingerprint`. Without it verification fails on every Play install, the link opens the browser instead of your app, and nothing reports an error. diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 9c95f57b701..a1419e76c8a 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -229,9 +229,10 @@ static void register(List h) { .doc("Whether the build generates and embeds the App Clip that makes invite " + "attribution exact on iOS. The App Store carries no referrer of its " + "own, so without the clip an iOS install can't be attributed at all. " - + "Set it to `false` only if you ship an App Clip of your own; the build " - + "then writes no clip, and the app reports every install as organic " - + "unless your clip writes the handoff itself. Ignored when " + + "Set it to `false` only if you ship an App Clip of your own: the " + + "build then generates no clip, but the app still carries the shared " + + "app group and the reader, so a clip of yours that writes the " + + "documented handoff is still picked up. Ignored when " + "`ios.invite.universalLinks` is `false`, because iOS can only offer a " + "clip for a link the app has an associated domain for.")); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index bed334f6de2..191836d34e1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1437,6 +1437,11 @@ private java.util.Set foldInCallAndVpnLibraryUsage( /// generated stub tests before registering a reader. private String inviteAppClipGroup = ""; + /// Whether to GENERATE the clip, which is a narrower question than whether + /// to read a handoff. A developer shipping their own clip turns this off + /// and still needs the app group, the native reader and the registration. + private boolean inviteAppClipTargetWanted; + /// The App Group the Call Directory extension and the app share. private String callDirectoryAppGroup; @@ -4517,9 +4522,12 @@ public void usesClassMethod(String cls, String method) { throw new BuildException( "Failed to enable CN1_INCLUDE_INVITE_APPCLIP", ex); } - debug("Invite attribution: generating the App Clip " - + InviteAppClipBuilder.CLIP_NAME + " for " + inviteHost - + " (app group " + group + ")"); + debug("Invite attribution: " + (inviteAppClipTargetWanted + ? "generating the App Clip " + + InviteAppClipBuilder.CLIP_NAME + : "reading a handoff from an App Clip this build " + + "does not generate") + + " for " + inviteHost + " (app group " + group + ")"); } if (request.getArg("ios.associatedDomains", null) != null) { @@ -7385,7 +7393,7 @@ && conditionCovers(governingKey, appendWidgetExtensionTargets(appExtensionsBuilder, request, new File(tmpFile, "dist")); } - if (inviteAppClipGroup.length() > 0) { + if (inviteAppClipTargetWanted) { // Same ordering note: appended after the global deployment-target // pass, so the clip keeps its own iOS 14 floor -- which is not a // preference. App Clips do not exist below it, and one built against @@ -12276,9 +12284,18 @@ displayName, embeddedExtensionShortVersion(request), /// here; the enablement block adds the group private void resolveInviteAppClipGroup(BuildRequest request) throws BuildException { inviteAppClipGroup = ""; + inviteAppClipTargetWanted = false; + // NOT gated on ios.invite.appClip, and that separation is the point. + // + // That hint says "do not GENERATE a clip", which a developer sets when + // they ship one of their own. It used to suppress the receiving side + // too -- the app group, the native define and the registration of + // IOSAppClipHandoff -- so a custom clip could write the documented + // handoff into the documented container and nothing in the + // application ever read it. Every install settled as no_match, for a + // clip that did its job. if (!usesInvites - || !"true".equals(request.getArg("ios.invite.universalLinks", "true")) - || !"true".equals(request.getArg("ios.invite.appClip", "true"))) { + || !"true".equals(request.getArg("ios.invite.universalLinks", "true"))) { return; } String group = request.getArg("ios.invite.appGroup", @@ -12290,6 +12307,8 @@ private void resolveInviteAppClipGroup(BuildRequest request) throws BuildExcepti + "\"group.\", got \"" + group + "\"."); } inviteAppClipGroup = group; + inviteAppClipTargetWanted = + "true".equals(request.getArg("ios.invite.appClip", "true")); } /// Emits the App Clip target into the schemes ruby. @@ -12333,9 +12352,13 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", name + "/" + name + ".entitlements"); buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", InviteAppClipBuilder.DEPLOYMENT_TARGET); - // iPhone only. App Clips do not run on iPad-only or Mac destinations, - // and a clip claiming a family the host does not ship fails validation. - buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1"); + // The HOST's families, through the same helper every other embedded + // target here uses. Hard-coding iPhone was wrong twice over: App Clips + // do run on iPad, and an ios.project_type=ipad build has an iPad-only + // app target -- so an iPhone-only clip inside it shares no family with + // its container and App Store validation rejects the archive. + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", + embeddedExtensionDeviceFamily(request.getArg("ios.project_type", "ios"))); buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks"); buildSettingsMap.put("SKIP_INSTALL", "YES"); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java index b18bb8f1da0..2bb499d2d35 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java @@ -175,4 +175,21 @@ void aQuotedDisplayNameCannotEscapeItsLiteral() { InviteAppClipBuilder.escapeObjC("Bob\"s \\ App")); assertEquals("one two", InviteAppClipBuilder.escapeObjC("one\ntwo")); } + /// The build hint documentation is part of the contract here, because the + /// two halves it governs are easy to conflate: the value of + /// ios.invite.appClip decides whether a clip is GENERATED, and never + /// whether a handoff is read. A developer shipping their own clip turns + /// generation off and still needs the app group, the native reader and the + /// registration -- without them their clip writes the documented handoff + /// into the documented container and nothing ever looks. + @Test + void theClipNameAndSuffixAreTheOnesTheServerAuthorises() { + // BuildCloud names each clip ..Clip in the association + // document, from its own copy of this suffix. The two repositories + // share no code, so a rename here is not a compile error there -- it + // is a clip iOS is never offered, with nothing reporting why. + assertEquals(".Clip", InviteAppClipBuilder.bundleId("x").substring(1)); + assertEquals("CN1InviteClip", InviteAppClipBuilder.CLIP_NAME); + } + } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java index 32c91f13de0..0201414d798 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java @@ -127,6 +127,25 @@ void dimensionsFromTheCurrentIdentityAreKept() { assertEquals("pro", loaded.get("plan")); } + @FormTest + void anUnstampedFileIsNotTrustedWithFrameworkDimensions() { + // The case a read-back check cannot reach and the stamp only covers if + // it is treated strictly: a file whose stamp was never written -- + // because it predates the stamp, or because the same storage failure + // that broke the erasure also stopped the stamp landing. Treating an + // absent stamp as current is exactly the state being defended against. + Analytics.clearProviders(); + Analytics.clearDimensions(); + Analytics.simulateSurvivingDimensionsForTest( + "cn1_campaign\tspring\nplan\tpro", null); + + Map loaded = Analytics.getDimensions(); + assertNull(loaded.get("cn1_campaign"), + "an unstamped referral was trusted and reloaded"); + assertEquals("pro", loaded.get("plan"), + "the application's own dimension was destroyed with it"); + } + @FormTest void setUserIdRequiresPersonalizationConsent() { Analytics.clearProviders(); From 6d7a38f5927423cf025198e2d85a82acc92a05fb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:49:25 +0300 Subject: [PATCH 50/70] Invites: a deletion tombstone read as pending, and a false privacy claim InviteStore.delete() overwrites a record it could not remove with an empty one, deliberately: an empty record carries no code, no inviter and no campaign, so a delete that cannot happen at least leaves nothing behind. But the state read defaulted an absent "state" key to STATE_PENDING, so that tombstone came back as a pending lookup -- and under re-attribution a pending state outranks the durable attribution, so the settled claim was resubmitted and invite_install or invite_opened counted one install twice. An empty record reads as absent now. And the class documentation still told applications that this feature writes a coarse device profile -- OS version, hardware model, language, screen size -- to local storage before consent. It has not since App Clips replaced the statistical match: pendingRecord() stores timing and state, and the code it keeps is one the person produced by tapping an invite. That is worse than a stale comment. It is the paragraph a developer copies into their own privacy disclosure, so leaving it there publishes a claim about data collection that does not happen -- and it would reasonably put someone off the feature entirely. It now says what is actually stored. --- .../codename1/analytics/invite/Invites.java | 32 +++++++++++++------ .../invite/InviteResilienceTest.java | 25 +++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 20674396ee7..478a70001c8 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -90,15 +90,17 @@ /// Everything reported here is gated on the analytics consent category of /// [Analytics], and nothing is transmitted until consent is granted. /// -/// One thing does happen before consent: on first launch a coarse device -/// profile -- operating system version, hardware model, language, screen size -/// -- is written to local storage so that a deferred match is still possible -/// once consent arrives. It is never transmitted while consent is withheld, -/// and it is deleted outright if consent is refused. There is no alternative -/// that also works, because the window in which a deferred match can be made -/// closes within the hour, long before a typical consent prompt is answered. -/// [#setAttributionWindow] with `0` switches deferred attribution off -/// entirely. +/// Nothing about the device is collected, before consent or after it. An +/// earlier design wrote a coarse profile -- operating system version, +/// hardware model, language, screen size -- to local storage on first launch, +/// because an iOS install could then be matched to a click statistically. +/// App Clips removed the need: the clip is launched by the invite link and is +/// handed the code itself, so there is nothing to match and nothing to keep. +/// +/// What is stored locally is the invite code and the bookkeeping around it -- +/// a state, a deadline, an attempt count -- and the code is only ever one the +/// person produced by tapping an invite. [#setAttributionWindow] with `0` +/// switches deferred attribution off entirely. /// /// ### How exact the answer is /// @@ -775,7 +777,17 @@ private static void loadState() { // the shape PMD reads as an unsynchronized lazy singleton, and the // answer to that is not a lock: this facade runs on the EDT and adding // one would be the real mistake. - int recorded = pending == null ? STATE_NONE + // An EMPTY record reads as absent, not as pending. + // + // InviteStore.delete() overwrites a record it could not remove with an + // empty one, deliberately -- an empty record carries no code, no + // inviter and no campaign, so a delete that cannot happen at least + // leaves nothing behind. But the default below turned that tombstone + // into STATE_PENDING on the next launch, and under re-attribution a + // pending state outranks the durable attribution: the settled claim + // was resubmitted and invite_install or invite_opened emitted a second + // time for one install. + int recorded = pending == null || pending.isEmpty() ? STATE_NONE : InviteStore.getInt(pending, "state", STATE_PENDING); // The pending record is consulted first only under re-attribution. // There a later invite writes a new claim while the earlier attribution diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index b4d8cf95e9c..72126e89133 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -578,6 +578,31 @@ public void requestReferrer(InstallReferrerCallback callback) { "an exact 'no invite' answer was left pending"); } + @FormTest + void thedeleteTombstoneIsNotMistakenForAPendingLookup() { + // InviteStore.delete() overwrites a record it could not remove with an + // empty one, on purpose: an empty record carries no code, no inviter + // and no campaign, so a delete that cannot happen leaves nothing + // behind. But an absent "state" key defaulted to STATE_PENDING, and + // under re-attribution a pending state outranks the durable + // attribution -- so the settled claim was resubmitted and the install + // funnel counted one install twice. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.setReattribution(true); + Invites.handleResolution( + InviteTestSupport.resolvedJson("SETTLED1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + // The tombstone a failed delete leaves. + InviteStore.write(InviteStore.PENDING, new java.util.LinkedHashMap()); + Invites.forgetLoadedState(); + + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "an empty deletion tombstone reopened a settled attribution"); + } + @Test @EdtTest void theReferrerCodeIsPersistedBeforeTheClaimGoesOut() { From fb39ecfc8e29b5932a030e1282addc1b340b0cd2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:59:04 +0300 Subject: [PATCH 51/70] Invites: a settled claim whose record refused to go was asked again delete() falls back to overwriting a record it cannot remove with an empty one, and the previous commit stopped that empty record reading as pending. This is the case where BOTH fail: the real pending state survives beside the new attribution, and under re-attribution loadState() prefers it -- deliberately, so a claim interrupted by process death is retried. The already-successful claim was therefore resubmitted on the next launch, and a second invite_install or invite_opened was emitted for one install. The record is overwritten with the terminal state when the delete fails. That says what the deletion would have said, in a record the store has just proved it will not remove, and it carries no code and no inviter -- so if that write fails too, what is left is the record that was already there and nothing new is disclosed. The failure is logged rather than assumed away. Revert-probed: with the check removed the test reports the settled install coming back as pending, which is the resubmission exactly. --- .../codename1/analytics/invite/Invites.java | 23 +++++++++++++++++- .../invite/InviteResilienceTest.java | 24 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 478a70001c8..128359cd274 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2679,7 +2679,28 @@ private static void resolve(InviteAttribution a, String confidence) { + "pending and will be retried", Log.WARNING); return; } - InviteStore.delete(InviteStore.PENDING); + if (!InviteStore.delete(InviteStore.PENDING)) { + // The claim is settled and its record could not be removed, nor + // overwritten with the empty one delete() falls back to. Under + // re-attribution loadState() prefers a surviving pending record + // over the durable attribution -- deliberately, so a claim + // interrupted by process death is retried -- so leaving this one + // there resubmits a claim that already succeeded, and a second + // invite_install or invite_opened is emitted for one install. + // + // Overwritten with the terminal state instead of deleted. That + // says the same thing the deletion would have, in a record the + // store has just proved it will not remove, and it carries no + // code and no inviter -- so if this write fails too, what is left + // is the record that was already there and nothing new is + // disclosed. + Map settled = new LinkedHashMap(); + settled.put("state", String.valueOf(STATE_RESOLVED)); + if (!writePending(settled)) { + Log.p("invite: the pending record survived a resolved claim and could not be " + + "marked settled; this install may be attributed again", Log.WARNING); + } + } forgetPendingFallback(); resolved = a; attributionLoaded = true; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 72126e89133..4ffece5873a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -603,6 +603,30 @@ void thedeleteTombstoneIsNotMistakenForAPendingLookup() { "an empty deletion tombstone reopened a settled attribution"); } + @FormTest + void asettledClaimWhosePendingRecordSurvivesIsNotAskedAgain() { + // The store refuses to delete the record AND refuses the empty + // overwrite delete() falls back to, so the real pending state lives on + // beside the new attribution. Under re-attribution loadState() prefers + // that record -- deliberately, so a claim interrupted by process death + // is retried -- and the already-successful claim was resubmitted, + // emitting a second invite_install for one install. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/PENDSURV"); + + InviteStore.failNextDeleteForTest(InviteStore.PENDING); + Invites.handleResolution( + InviteTestSupport.resolvedJson("PENDSURV", "spring", "sms"), + Invites.MATCH_DIRECT, false); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "a settled claim was left pending and would be submitted again"); + } + @Test @EdtTest void theReferrerCodeIsPersistedBeforeTheClaimGoesOut() { From 7066438f034fd3ceaa710aaa4286365560a094df Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:10:57 +0300 Subject: [PATCH 52/70] Invites: the App Clip embed named the target, not the product ios.invite.buildSettings.PRODUCT_NAME can override what the clip is built as, but the embed reference looked for CN1InviteClip.app in BUILT_PRODUCTS_DIR regardless -- so such a build failed while copying a product that was never produced. It goes through effectiveExtensionProductName, the same helper the VPN tunnel and Matter targets use, which also refuses a value this build cannot evaluate rather than emitting a reference that cannot resolve: an Xcode condition is legal in that setting and nothing here can expand it, so the honest answer is to say which hint is unusable and why. --- .../com/codename1/builders/IPhoneBuilder.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 191836d34e1..bcbe3d39183 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -12375,6 +12375,20 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, request.getArg(key, "")); } } + // The name the product will ACTUALLY be built under, which is not + // necessarily the target name: ios.invite.buildSettings.PRODUCT_NAME + // can override it. The embed reference below names a file in + // BUILT_PRODUCTS_DIR, so hard-coding the target name made such a build + // fail while copying a product that was never produced. + String productName = effectiveExtensionProductName( + buildSettingsMap.get("PRODUCT_NAME"), name); + if (productName == null) { + throw new BuildException("ios.invite.buildSettings.PRODUCT_NAME is \"" + + buildSettingsMap.get("PRODUCT_NAME") + "\", which this build" + + " cannot evaluate, so it cannot know what the App Clip's" + + " product will be called or embed it in the app. Use a" + + " literal name, or $(TARGET_NAME)."); + } // Guarded so re-running the script does not create a duplicate target; // the build re-executes fix_xcode_schemes.rb after dependency // integration. @@ -12392,7 +12406,7 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, sb.append("main_app_target = xcproj.targets.find{|e| e.name==main_class_name}\n" + "main_app_target.add_dependency(clip_target)\n" + "fileref = xcproj.groups.find{|e| e.display_name=='Products'}.new_file('" - + name + ".app', \"BUILT_PRODUCTS_DIR\")\n" + + productName + ".app', \"BUILT_PRODUCTS_DIR\")\n" + "embed_phase = main_app_target.copy_files_build_phases.find{|p| " + "p.name=='Embed App Clips'} || " + "main_app_target.new_copy_files_build_phase('Embed App Clips')\n" From 4e39d4f87366fef62d4be0b247dd46a900bebd03 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:24:52 +0300 Subject: [PATCH 53/70] Invites: a burst of mints resent the whole queue, and a hint too much Outbox entries leave the queue only when their OWN response acknowledges them -- which is right, because the campaign, channel, payload and preview cannot be reconstructed from a click -- but that leaves an entry drainable while its request is still outstanding. create() flushes unconditionally, so N invites minted in a burst sent N(N+1)/2 requests: six produced twenty-one, and the 512-entry cap puts a full queue past 131,000. Entries now carry an in-flight mark, and the two flushes are told apart. create()'s own flush skips what is already going out; the PUBLIC flush() does not, because it is documented as the "I have just regained connectivity" call and its whole job is resending a request that went out over a dead network and will never answer. An existing test pins that second behaviour and caught the first attempt, which suppressed both. The mark is released on every outcome, including handleException -- where postResponse() never runs. Without that a transport failure left the entry marked for the life of the process and no later drain retried it, trading an amplification bug for a lost registration, which is the worse of the two. It is not persisted, so a process that dies with requests outstanding retries them on the next launch. And ios.invite.universalLinks=false disabled the receiving side, exactly as ios.invite.appClip=false did before it. It means "do not inject the associated domain, I manage the entitlement myself" -- and it was also suppressing the app group, the native define and the registration of IOSAppClipHandoff, so an app that had configured its own domains correctly had nothing reading the handoff and every iOS install settled as no_match. Each hint is applied where the thing it governs is done, and neither gates the machinery any more. --- .../codename1/analytics/invite/Invites.java | 69 ++++++++++++++++++- .../com/codename1/builders/IPhoneBuilder.java | 26 ++++--- .../analytics/invite/InviteMintTest.java | 22 ++++++ 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 128359cd274..330a4cf0e7a 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -382,7 +382,10 @@ public static Invite create(InviteRequest request) { putIfSet(p, "campaign", request.getCampaign()); putIfSet(p, "channel", request.getChannel()); Analytics.autoEvent("invite_created", CATEGORY, p); - flush(); + // Skipping what is already on the wire. Every create() flushes, so + // without this a burst of N invites sent N(N+1)/2 requests -- each + // mint resending every earlier one, none of which needed it. + flush(true); return invite; } @@ -974,8 +977,15 @@ public static boolean isReattribution() { /// deferred match. Called for you on the paths that matter; exposed for /// an application that knows it has just regained connectivity. public static void flush() { + flush(false); + } + + /// - `skipInFlight`: true for the flush create() issues itself, which must + /// not resend a queue that is already going out; false for the public + /// call, which exists precisely to resend after a network came back. + private static void flush(boolean skipInFlight) { ensureProvider(); - drainOutbox(); + drainOutbox(skipInFlight); // A deferred lookup that failed because the first launch was offline // leaves deferredStarted set, and nothing else clears it inside the // process: the request is fail-silent, so no callback runs. Without @@ -2402,8 +2412,29 @@ protected void readResponse(InputStream input) throws IOException { payload = new String(Util.readInputStream(input), "UTF-8"); } + @Override + protected void handleException(Exception err) { + // The transport failed, so postResponse() never runs. Without this + // the entry stayed marked in flight for the life of the process + // and no later flush would retry it -- trading an amplification + // bug for a lost registration, which is the worse of the two. + releaseInFlight(); + super.handleException(err); + } + + private void releaseInFlight() { + if (registration && outboxEntry != null) { + inFlight.remove(outboxEntry); + } + } + @Override protected void postResponse() { + // Cleared before the failure check, because a failed send has to be + // retryable by the next drain: this mark exists only to stop one + // burst of invites reposting the whole queue, not to retire an + // entry. + releaseInFlight(); // Reading the body of an error response is on by default // (ConnectionRequest.readResponseForErrorsDefault), and the error // path falls through to postResponse() exactly as a 200 does. So @@ -2876,6 +2907,21 @@ private static void notifyUnavailable(String reason) { // the durable store is the thing that just failed. private static final List unacknowledged = new ArrayList(); + // Outbox entries with a request already on the wire, keyed by the entry + // exactly as the queue holds it. + // + // Entries leave the queue only when their OWN response acknowledges them, + // which is right -- the metadata cannot be reconstructed from a click -- + // but it means an entry stays drainable while its request is outstanding. + // create() calls flush() unconditionally, so minting invites in a burst + // reposted the whole queue each time: N invites produced N(N+1)/2 + // requests, and the 512-entry cap puts that over 131,000. Each invite + // needs exactly one. + // + // Not persisted: a process that dies with requests outstanding should + // retry them, and an empty set on the next launch is what makes it. + private static final List inFlight = new ArrayList(); + /// Records that a queued registration was evicted to keep the outbox /// under its cap. /// @@ -2941,7 +2987,19 @@ private static boolean queueRegistration(Invite invite, InviteRequest request) { return InviteStore.writeOutbox(outbox); } + /// The ordinary drain: entries with a request already on the wire are + /// skipped. private static void drainOutbox() { + drainOutbox(true); + } + + /// - `skipInFlight`: false for an explicit [#flush], which is the + /// documented "I have just regained connectivity" call and must resend + /// an entry whose request went out over a dead network and will never + /// answer. true everywhere else, including the flush create() issues + /// itself -- that one is what turned a burst of N invites into N(N+1)/2 + /// requests, and no invite in a burst needs its predecessors resent. + private static void drainOutbox(boolean skipInFlight) { if (!allowed()) { return; } @@ -2968,6 +3026,13 @@ private static void drainOutbox() { // Re-posting an entry that did land is harmless: the server keys on // the code and treats a repeat from the same inviter as idempotent. for (String json : outbox) { + if (skipInFlight && inFlight.contains(json)) { + // Already on the wire. Its response will remove it or leave it + // for the next drain; sending it again buys nothing and is how + // one burst of invites became thousands of requests. + continue; + } + inFlight.add(json); // The body is rewritten, the KEY is not. The outbox still holds the // original string, and that is what has to be removed when the // server accepts it. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index bcbe3d39183..2c20c76b1d3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -12285,17 +12285,23 @@ displayName, embeddedExtensionShortVersion(request), private void resolveInviteAppClipGroup(BuildRequest request) throws BuildException { inviteAppClipGroup = ""; inviteAppClipTargetWanted = false; - // NOT gated on ios.invite.appClip, and that separation is the point. + // Gated on usesInvites and NOTHING else, which is the point. // - // That hint says "do not GENERATE a clip", which a developer sets when - // they ship one of their own. It used to suppress the receiving side - // too -- the app group, the native define and the registration of - // IOSAppClipHandoff -- so a custom clip could write the documented - // handoff into the documented container and nothing in the - // application ever read it. Every install settled as no_match, for a - // clip that did its job. - if (!usesInvites - || !"true".equals(request.getArg("ios.invite.universalLinks", "true"))) { + // Both hints here say "do not do this FOR me", and both were reading + // as "turn the feature off". ios.invite.appClip says do not generate a + // clip, which a developer sets when they ship one of their own; + // ios.invite.universalLinks says do not inject the associated domain, + // which they set when they manage the entitlement by hand. Either one + // used to suppress the receiving side as well -- the app group, the + // native define and the registration of IOSAppClipHandoff -- so a + // correctly configured app whose own clip wrote the documented handoff + // into the documented container had nothing reading it, and every iOS + // install settled as no_match. + // + // What each hint governs is applied where that thing is done: the + // domain append is guarded by universalLinks at its own call site, and + // target generation by appClip just below. + if (!usesInvites) { return; } String group = request.getArg("ios.invite.appGroup", diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java index b29c0f7e708..55670504925 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java @@ -184,4 +184,26 @@ public void execute() { }); assertTrue(e2.getMessage().contains("campaign"), e2.getMessage()); } + @FormTest + void aburstOfInvitesSendsOneRequestEach() { + // Entries leave the outbox only when their OWN response acknowledges + // them, which is right -- the campaign, channel, payload and preview + // cannot be reconstructed from a click -- but it leaves an entry + // drainable while its request is outstanding. create() calls flush() + // unconditionally, so a burst reposted the whole queue each time: N + // invites produced N(N+1)/2 requests, and the 512-entry cap puts that + // past 131,000 for a full queue. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + implementation.clearQueuedRequests(); + + int burst = 6; + for (int i = 0; i < burst; i++) { + Invites.create(InviteRequest.create().campaign("c" + i).build()); + } + + assertEquals(burst, implementation.getQueuedRequests().size(), + "a burst of " + burst + " invites did not send one request each"); + } + } From 23ef7ab60c5beb36eb049f33efc640f56ee6be6b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:41:09 +0300 Subject: [PATCH 54/70] Invites: three erasures that reported success and left something behind All three are the same shape, and it is the shape Preferences forces: `set` updates a static table and swallows the store's answer, so no write to it can be verified. Everything durable that CAN report -- the three InviteStore records -- already does. These are the places that trusted the other kind. reset() cleared the referral dimensions in memory and asked Preferences to persist that, while resetVerified() reported success on the records it can verify. A plain reset keeps the same client id, so the owner stamp still matched and the next launch loaded the old cn1_invite* values back and transmitted them, despite reset() promising to forget them. The attribution record is the authority and it IS verifiable, so the dimensions are reconciled against it once per process: if no record stands behind them, they are the stale copy and the erasure finishes on the next launch instead. That is the best an unverifiable store allows, and it is self-healing rather than dependent on the failing write ever succeeding. The provider's identity baseline had the mirror problem. A failed baseline write leaves the same empty value a first registration does -- so the next resetClientId() in that process read the new id as its first baseline, skipped eraseInternal(), and left the old attribution and the queued registrations attached to the identity just reset. Records with no baseline are treated as the erasure that never completed; a device with no records is the genuine first registration it looks like. And when both the PENDING delete and its settled-marker replacement failed, the held fallback was discarded anyway -- committing the resolution with a durable STATE_PENDING on the disk, which re-attribution prefers, so the next launch resubmitted a claim that had already succeeded. The fallback is kept when its own write failed, so the next read retries it. The first is revert-probed: without the reconciliation the test reports the erased campaign still reading "spring". --- .../invite/InviteAttributionProvider.java | 25 +++++- .../codename1/analytics/invite/Invites.java | 82 ++++++++++++++++++- .../invite/InviteConsentAndErasureTest.java | 28 +++++++ 3 files changed, 130 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index 47f291b1eb2..d41ba320415 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -68,9 +68,28 @@ public void init(AnalyticsContext context) { } String last = Preferences.get(PREF_LAST_CLIENT_ID, ""); if (last == null || last.length() == 0) { - // First registration on this device. Record the baseline; this is - // a provider being added, not an identity being erased. - Preferences.set(PREF_LAST_CLIENT_ID, seen); + // No baseline. Which of two things that means is decided by + // whether this device has invite records, because Preferences + // cannot be asked whether a write landed: set() updates a static + // table and swallows the store's answer. + // + // A genuinely first registration has no records, and recording the + // baseline is all there is to do. But a baseline write that failed + // earlier leaves the same empty value beside records that DO + // exist -- and the next resetClientId() in that process then read + // the new id as its first baseline, skipped eraseInternal(), and + // left the old attribution and the queued registrations attached + // to the identity the user had just reset. + // + // Records with no baseline are therefore treated as the erasure + // that never completed, and the baseline advances only once it has. + if (!Invites.hasDurableRecords()) { + Preferences.set(PREF_LAST_CLIENT_ID, seen); + return; + } + if (Invites.eraseInternal()) { + Preferences.set(PREF_LAST_CLIENT_ID, seen); + } return; } if (!last.equals(seen)) { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 330a4cf0e7a..3d58b88cd24 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1137,6 +1137,12 @@ static int currentLookupEpochForTest() { return lookupEpoch; } + // Package private test seam: lets a test model the next launch, where the + // reconciliation runs again. + static void forgetDimensionReconciliationForTest() { + dimensionsReconciled = false; + } + // Package private test seam: drops the in-memory copy so the next read // comes off the disk, which is what the next process would do. static void forgetCachedAttributionForTest() { @@ -1145,6 +1151,21 @@ static void forgetCachedAttributionForTest() { stateLoaded = false; } + /// Whether this device carries any durable invite record. + /// + /// Asked by the provider when it finds no identity baseline: with records + /// present that is a baseline write that failed rather than a first + /// registration, and the difference decides whether the next identity + /// change erases or is quietly accepted as the first one seen. + /// + /// #### Returns + /// + /// true when an attribution, a pending record or a queued registration + /// exists + static boolean hasDurableRecords() { + return anythingSurvives(); + } + // Package private: the analytics provider hook calls this when the client // id changes underneath us, which is what an erasure request looks like. static boolean eraseInternal() { @@ -1337,6 +1358,7 @@ static void onConsentChanged(boolean allowed) { // catalog's prefix and put a Play dependency and an API floor on every // application that logs a single event. private static void ensureProvider() { + reconcileDimensions(); try { List providers = Analytics.getProviders(); for (Object provider : providers) { @@ -2710,6 +2732,7 @@ private static void resolve(InviteAttribution a, String confidence) { + "pending and will be retried", Log.WARNING); return; } + boolean pendingCleared = true; if (!InviteStore.delete(InviteStore.PENDING)) { // The claim is settled and its record could not be removed, nor // overwritten with the empty one delete() falls back to. Under @@ -2728,11 +2751,19 @@ private static void resolve(InviteAttribution a, String confidence) { Map settled = new LinkedHashMap(); settled.put("state", String.valueOf(STATE_RESOLVED)); if (!writePending(settled)) { + // Both the delete and the replacement failed, so the held copy + // is the only record of what the store should say. Discarding + // it committed the resolution with a durable STATE_PENDING + // still on the disk -- which re-attribution prefers -- and the + // next launch resubmitted a claim that had already succeeded. + pendingCleared = false; Log.p("invite: the pending record survived a resolved claim and could not be " - + "marked settled; this install may be attributed again", Log.WARNING); + + "marked settled; the correction is held and retried", Log.WARNING); } } - forgetPendingFallback(); + if (pendingCleared) { + forgetPendingFallback(); + } resolved = a; attributionLoaded = true; state = STATE_RESOLVED; @@ -2765,6 +2796,53 @@ private static void writeDimensions(InviteAttribution a) { Analytics.setDimension(DIMENSION_MATCH, a.getMatchType()); } + // Whether this process has already reconciled the dimensions with the + // durable record. Once is enough: nothing between here and the next launch + // can put the two back out of step without going through writeDimensions + // or clearDimensions. + private static boolean dimensionsReconciled; + + /// Drops referral dimensions that no durable attribution stands behind. + /// + /// The dimensions live in Preferences, whose writes cannot be verified -- + /// `Preferences.set` updates a static table and swallows the store's + /// answer -- so `reset()` could clear them in memory, fail to persist, and + /// report success: `resetVerified()` only tracks the three InviteStore + /// records, which DO report. A plain reset keeps the same client id, so + /// the owner stamp still matched and the next launch loaded the old + /// `cn1_invite*` values straight back and transmitted them. + /// + /// The attribution record is the authority and it is verifiable. If it is + /// gone and the dimensions are not, the dimensions are the stale copy, and + /// the erasure finishes here instead -- on the next launch rather than the + /// failing one, which is the best any unverifiable store allows. + private static void reconcileDimensions() { + if (dimensionsReconciled) { + return; + } + dimensionsReconciled = true; + try { + if (readAttribution() != null) { + return; + } + Map set = Analytics.getDimensions(); + if (set == null) { + return; + } + for (String dimension : DIMENSIONS) { + if (set.get(dimension) != null) { + // One of them surviving means all of them are suspect; + // clearDimensions() drops the whole set the framework owns + // and leaves the application's own alone. + clearDimensions(); + return; + } + } + } catch (Throwable t) { + Log.e(t); + } + } + private static void clearDimensions() { for (String dimension : DIMENSIONS) { Analytics.clearDimension(dimension); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index 0ccf7557451..ad707eef6a2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -298,6 +298,34 @@ void emptyingTheQueueIsVerifiedLikeEveryOtherDelete() { assertTrue(InviteStore.readOutbox().isEmpty(), "the queue survived"); } + @FormTest + void referralDimensionsWithNoRecordBehindThemAreDropped() { + // reset() clears the dimensions in memory and asks Preferences to + // persist that -- and Preferences cannot say whether it did: set() + // updates a static table and swallows the store's answer, so + // resetVerified() reported success on the three InviteStore records it + // CAN verify while the old values stayed on the disk. A plain reset + // keeps the same client id, so the owner stamp still matched and the + // next launch loaded the referral straight back and transmitted it. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("GHOST1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertEquals("spring", Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN)); + + // The durable record goes; the dimensions are left behind, which is + // what an unpersisted clear looks like on the next launch. + assertTrue(InviteStore.delete(InviteStore.ATTRIBUTION)); + Invites.forgetCachedAttributionForTest(); + Invites.forgetDimensionReconciliationForTest(); + + Invites.checkForInvite(); + + assertNull(Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN), + "a referral with no record behind it was kept and would be transmitted"); + } + @FormTest void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); From cd49dddb922379ad8c41c9df977d3558bca690a0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:55:06 +0300 Subject: [PATCH 55/70] Invites: an app with no invites would not have linked CN1InviteAppClip.m put both native implementations behind CN1_INCLUDE_INVITE_APPCLIP, but IOSNative.java declares the methods unconditionally -- and ParparVM needs a symbol for every native declaration whether or not anything reaches it. So an ordinary iOS application that never heard of invites would have failed to LINK, on a feature it does not use, which is the worst possible place for this to be felt. I reasoned that dead-code elimination would drop the unreferenced Java methods along with the class nothing registers. That was wrong, and the file next door says so in as many words: CN1WebAuthn.m supplies #else stubs for exactly this reason and explains it. This now does the same, answering what a device with no clip answers anyway, so nothing depends on which branch compiled. Verified by compiling the file with the define OFF. SERVICE_DISCONNECTED arriving as a RESPONSE CODE fell into the terminal default, which records PREF_ATTEMPTED and refuses another read for ever. It is the same transient state the disconnect callback reports, and handling that callback -- as this branch already does -- does not cover this path: an invited install whose exact Play referrer was still available on the next connection settled permanently as organic. It takes the transient route now, so the once-only flag stays unburnt. --- .../referrer/AndroidInstallReferrer.java | 12 ++++++++++ .../iOSPort/nativeSources/CN1InviteAppClip.m | 24 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index d158354f827..2cf2b5b29f7 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -152,6 +152,18 @@ public void onInstallReferrerSetupFinished(int responseCode) { // read exactly. unavailable(issued, callback, Invites.REASON_NO_MATCH); break; + case InstallReferrerClient.InstallReferrerResponse.SERVICE_DISCONNECTED: + // The SAME transient state the disconnect callback + // reports, arriving through the response code + // instead -- and it is a separate path, so + // handling one and not the other left this one + // falling into the terminal default below. That + // burnt the once-only flag and permanently refused + // another read, for an invited install whose exact + // Play referrer was still there on the next + // connection. + unavailable(issued, callback, Invites.REASON_NO_MATCH); + break; default: // FEATURE_NOT_SUPPORTED is the ordinary answer on a // device with no Play Store -- a sideload, an diff --git a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m index 04d040793e0..d0ff2d3b6e1 100644 --- a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m +++ b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m @@ -121,4 +121,26 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_consumeAppClipInviteHandoff___java_ return fromNSString(CN1_THREAD_STATE_PASS_ARG joined); } -#endif +#else + +// Stubs when CN1_INCLUDE_INVITE_APPCLIP is not defined: the build generated no +// App Clip and nothing registers IOSAppClipHandoff, so these natives are +// unreachable. ParparVM still needs the symbols to satisfy the native-method +// declarations on IOSNative.java, which are unconditional -- without them an +// ordinary application that never heard of invites fails to LINK, which is the +// worst place for this feature to be felt. +// +// The answers are the ones a device with no clip would give anyway, so nothing +// depends on which branch compiled. + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppClipHandoffSupported___java_lang_String_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + return JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_consumeAppClipInviteHandoff___java_lang_String_R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + return JAVA_NULL; +} + +#endif // CN1_INCLUDE_INVITE_APPCLIP From a4fb34201e28a5d6274691a5e8c2a064ab8f3462 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:10:00 +0300 Subject: [PATCH 56/70] Invites: the generated App Clip had no icon, so the archive could not upload ASSETCATALOG_COMPILER_APPICON_NAME was blanked on the clip target. An App Clip is a full application bundle and App Store validation rejects one with no icon, so every invite-enabled iOS archive would have been refused at upload -- for a target the developer never asked to maintain and cannot fix from their own sources. The host's Images.xcassets is copied into the clip rather than a placeholder generated: a clip card showing a different icon from the app it installs is its own confusion, and the person seeing it has installed nothing yet. appendFilesToXcodeProjGroup already adds an .xcassets directory as a single resource -- it has to, or Xcode fails with "Multiple commands produce Contents.json" -- so staging it is all that is needed. A build with no host catalog says so rather than naming a catalog that is not there, which would fail the build instead of the upload. The derived product name went into a single-quoted Ruby literal unescaped, so a legal PRODUCT_NAME containing an apostrophe -- "Friend's Clip" -- broke fix_xcode_schemes.rb and the iOS build with it. It goes through escapeRuby like every neighbouring target. And the ios.invite.appClip documentation still said it was ignored when ios.invite.universalLinks is false. That stopped being true when the two hints were separated: universalLinks now means "I manage the domains myself" and leaves the clip, the app group and the reader in place, so a developer relying on the old wording would get a second target and its signing requirements unannounced. appClip=false is the only thing that suppresses generation, and the entry says so. --- .../codename1/build/shared/BuildHintsIos.java | 8 +++-- .../com/codename1/builders/IPhoneBuilder.java | 29 +++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index a1419e76c8a..4e77b598fd5 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -232,9 +232,11 @@ static void register(List h) { + "Set it to `false` only if you ship an App Clip of your own: the " + "build then generates no clip, but the app still carries the shared " + "app group and the reader, so a clip of yours that writes the " - + "documented handoff is still picked up. Ignored when " - + "`ios.invite.universalLinks` is `false`, because iOS can only offer a " - + "clip for a link the app has an associated domain for.")); + + "documented handoff is still picked up. This is the ONLY hint that " + + "suppresses the clip: `ios.invite.universalLinks=false` means you " + + "manage the associated domains yourself and leaves the clip, the " + + "shared app group and the reader in place, because an app that " + + "configured its own domains correctly still needs them.")); h.add(new Hint("ios.invite.appGroup") .group(HintGroup.IOS) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 2c20c76b1d3..94c4059ffd0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -12373,7 +12373,32 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, // the way the port is. buildSettingsMap.put("CLANG_ENABLE_OBJC_ARC", "YES"); buildSettingsMap.put("CLANG_ENABLE_MODULES", "YES"); - buildSettingsMap.put("ASSETCATALOG_COMPILER_APPICON_NAME", ""); + // An App Clip is a full application bundle and App Store validation + // rejects one with no icon, so the clip carries a catalog of its own. + // + // Blanking this setting -- which is what was here -- produced an + // invite-enabled archive that could not be uploaded at all, for a + // target the developer never asked to maintain. The host's icons are + // copied rather than a placeholder generated: a clip card showing a + // different icon than the app it installs is its own confusion, and + // the person seeing it has not installed anything yet. + // + // appendFilesToXcodeProjGroup already adds an .xcassets directory as a + // single resource -- it has to, or Xcode fails with "Multiple commands + // produce Contents.json" -- so staging it here is all that is needed. + File clipIcons = new File(distDir, name + "/Images.xcassets"); + File hostIcons = new File(distDir, request.getMainClass() + "-src/Images.xcassets"); + if (hostIcons.isDirectory()) { + copyDirectory(hostIcons, clipIcons); + buildSettingsMap.put("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"); + } else { + // No host catalog to copy, which means this build has no icons at + // all and the app target has the same problem. Said out loud + // rather than shipping a setting that names a catalog that is not + // there, which fails the build instead of the upload. + log("Invite attribution: the application has no Images.xcassets, so the App Clip " + + "ships without an icon and the archive will be rejected"); + } for (String key : request.getArgs()) { if (key.startsWith("ios.invite.buildSettings.")) { buildSettingsMap.put( @@ -12412,7 +12437,7 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, sb.append("main_app_target = xcproj.targets.find{|e| e.name==main_class_name}\n" + "main_app_target.add_dependency(clip_target)\n" + "fileref = xcproj.groups.find{|e| e.display_name=='Products'}.new_file('" - + productName + ".app', \"BUILT_PRODUCTS_DIR\")\n" + + escapeRuby(productName) + ".app', \"BUILT_PRODUCTS_DIR\")\n" + "embed_phase = main_app_target.copy_files_build_phases.find{|p| " + "p.name=='Embed App Clips'} || " + "main_app_target.new_copy_files_build_phase('Embed App Clips')\n" From 801bfedd88282e7f553468976ca264037dc2b380 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:43:03 +0300 Subject: [PATCH 57/70] Invites: a tap time the refusal dropped, and a budget charged twice markTerminal() copies the code provenance into the reopenable DECLINED marker so a user who refuses consent when the link arrives and grants it afterwards keeps their exact claim. codeClicked was added to the record later and never added to that list, so a withdraw-then-grant cycle resent the claim with a zero time -- and for an App Clip that time is irrecoverable, because the invocation never reaches the redirect and the clip cleared its own copy as it was read. The clip handoff also charged the retry budget twice: the local read bumped attempts and the claim it leads to bumped them again, so the first network claim started at 2 and the install settled terminal after four requests instead of the five MAX_ATTEMPTS promises. The install-referrer path never bumped there, so the clip path was the inconsistent one; both now spend the budget only on network attempts, and a source that answers nothing at all is bounded by the attribution window on both. And the SpotBugs finding CI caught: a redundant null check on Analytics.getDimensions(), which returns a fresh copy and never null. That gate has been blind on my side all branch -- SpotBugs will not run under the JDK 8 toolchain, so I had been passing -Dspotbugs.skip=true. It runs under JAVA17_HOME, and the recipe needs stating because two earlier attempts reported clean without running at all: the report has to be DELETED first, and the run needs network access or checkstyle fails in the validate phase and spotbugs never executes, leaving the previous report to be read as success. --- .../codename1/analytics/invite/Invites.java | 29 +++++++--- .../analytics/invite/InviteDeliveryTest.java | 55 +++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 3d58b88cd24..09e2e450cd4 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1666,11 +1666,16 @@ private static boolean markTerminal(int terminalState, String reason) { // // A refusal is reopenable, so the code has to survive it: discarding it // meant 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. Four - // short fields, and none of them describes the device. + // afterwards had the exact claim replaced by a referrer read, which can + // miss or credit a different click. None of these describes the device. + // + // codeClicked belongs in this list for the same reason and was missed + // when it was added. An App Clip invocation never reaches the redirect, + // so the clip is the only witness to the tap -- and it cleared its own + // copy as it was read. Dropped here, a withdraw-then-grant cycle + // resent the claim with a zero time that nothing could ever recover. for (String key : new String[] {"code", "codeSource", "codeMatch", "codeDeferred", - "codeReferrer"}) { + "codeReferrer", "codeClicked"}) { InviteStore.put(done, key, InviteStore.get(before, key, null)); } if (!writePending(done)) { @@ -2184,7 +2189,15 @@ private static void requestAppClipHandoff(final Map pending) { settleNoHandoff(REASON_NO_MATCH); return; } - bumpAttempts(pending); + // NOT counted as an attempt. The claim this leads to bumps the + // counter itself, and charging the local handoff read as well started + // the first network claim at 2 -- so the install settled terminal + // after four requests instead of the five MAX_ATTEMPTS promises. + // + // The install-referrer path has never bumped here and is the shape + // this now matches. A source that answers nothing at all is bounded by + // the attribution window rather than by this counter, which is true of + // both paths equally. lookupIssuedAt = System.currentTimeMillis(); // The epoch this read was issued under, checked when it answers. // @@ -2825,10 +2838,10 @@ private static void reconcileDimensions() { if (readAttribution() != null) { return; } + // getDimensions() returns a fresh copy and never null, so there + // is nothing to guard here -- and SpotBugs, which is a + // zero-findings gate, says so. Map set = Analytics.getDimensions(); - if (set == null) { - return; - } for (String dimension : DIMENSIONS) { if (set.get(dimension) != null) { // One of them surviving means all of them are suspect; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index 4137a8c2814..93d4ea4ba5d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -23,6 +23,8 @@ package com.codename1.analytics.invite; import com.codename1.analytics.Analytics; +import com.codename1.analytics.ConsentMode; +import com.codename1.analytics.AnalyticsConsent; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertNull; import com.codename1.junit.FormTest; @@ -307,6 +309,59 @@ void theclipsTapTimeSurvivesIntoTheClaim() { "the claim did not carry the tap time as a number: " + body); } + @FormTest + void theclipsTapTimeSurvivesAConsentRefusal() { + // A refusal is reopenable, so the code survives it -- and the tap time + // has to travel with the code. An App Clip invocation never reaches + // the redirect, so the clip is the only witness, and it cleared its + // own copy as it was read. Dropped from the marker, a + // withdraw-then-grant cycle resends the claim with a zero time that + // nothing can recover. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + + // The clip answers while consent stands, so the code and its tap time + // are persisted and the claim goes out. + long tappedSeconds = System.currentTimeMillis() / 1000L - 900L; + Invites.checkForInvite(); + InviteTestSupport.pendingHandoff.answer("CLIPDENY", tappedSeconds); + assertEquals(tappedSeconds * 1000L, + InviteStore.getLong(InviteStore.read(InviteStore.PENDING), "codeClicked", 0), + "the fixture never persisted a tap time"); + + // Consent is withdrawn before the claim resolves, which writes the + // reopenable DECLINED marker. + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + Map marker = InviteStore.read(InviteStore.PENDING); + assertNotNull(marker, "the refusal left no marker"); + assertEquals("CLIPDENY", InviteStore.get(marker, "code", null), + "the fixture did not reach the reopenable marker"); + assertEquals(tappedSeconds * 1000L, + InviteStore.getLong(marker, "codeClicked", 0), + "the tap time did not survive the consent refusal"); + } + + @FormTest + void theclipHandoffReadIsNotChargedAsANetworkAttempt() { + // The claim bumps the counter itself. Charging the local handoff read + // too started the first network claim at 2, so the install settled + // terminal after four requests instead of the five MAX_ATTEMPTS + // promises -- and the referrer path, which never bumped here, got its + // full budget. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.checkForInvite(); + InviteTestSupport.pendingHandoff.answer("CLIPBUDGET"); + + Map record = InviteStore.read(InviteStore.PENDING); + assertNotNull(record); + assertEquals(1, InviteStore.getInt(record, "attempts", 0), + "the handoff read and its claim were both charged"); + } + @FormTest void aclipAnswerThatOutlivedItsLookupIsIgnored() { // The read is asynchronous and everything that supersedes a lookup From aa67da38b25198b8df55145d5e52fbf8721349b9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:01:35 +0300 Subject: [PATCH 58/70] Invites: an erasure that died with the process, and a mark nothing released erasurePending is a static, so a reset() whose deletes failed and whose application then exited left nothing to retry from -- and a plain reset keeps the client id, so the provider sees no identity change on the next launch and does not erase either. The surviving attribution came back and was transmitted, which is the one thing reset() promises cannot happen. There is a durable ERASURE record now, picked up once per process before anything can read or transmit. It is a WRITE recording a failure to DELETE, which is deliberate: a store refusing removals may still accept a small write, and if it refuses that too this is no worse than what came before. Revert-probed -- without the resume the test reports the erased attribution coming back. The in-flight mark was released by a callback that never runs. These requests are fail-silent, and NetworkManager's fail-silent branch only logs -- it never calls handleIOException or handleRuntimeException, so nothing reaches the request's own hooks. A transport failure left the entry marked for the life of the process and every automatic drain skipped it, trading an amplification bug for a registration only an explicit flush() or a restart would resend. It is a 60-second time bound now: a burst happens in milliseconds, so the bound serves the original purpose completely while guaranteeing the queue heals, and expired marks are dropped as they are read. The Play referrer's one-shot flag was burnt before the code was handed over, so a process killed in between lost the exact referrer for ever and the next launch settled the invited install as no-match. The handoff comes first now. Being precise about what that buys: Invites marshals onto the EDT, so a callback arriving on a binder thread has its persist QUEUED rather than done. The window goes from always to the callSerially latency, not to zero. Closing it completely would need the port to know what the framework did with the value, which the SPI deliberately does not tell it. And DM_NUMBER_CTOR on the new map -- new Long() where Long.valueOf() belongs. Caught by running SpotBugs locally this time rather than by CI. --- .../analytics/invite/InviteStore.java | 16 ++++ .../codename1/analytics/invite/Invites.java | 75 ++++++++++++++++++- .../referrer/AndroidInstallReferrer.java | 19 ++++- .../invite/InviteResilienceTest.java | 32 ++++++++ 4 files changed, 138 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index 5b9a474b612..b08d4d68cbf 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -51,6 +51,22 @@ final class InviteStore { // Mint registrations that have not reached the link service yet. static final String OUTBOX = "CN1$InviteOutbox"; + /// Records that an erasure was asked for and could not finish. + /// + /// Its own record because it has to outlive the process: `erasurePending` + /// is a static, so a reset() whose deletes failed and whose application + /// then exited left nothing behind to retry from -- and a plain reset + /// keeps the client id, so the provider sees no identity change on the + /// next launch and never erases either. The surviving attribution came + /// back and was transmitted, which is what reset() promises will not + /// happen. + /// + /// Tiny and written rather than deleted, because the failure being + /// recorded is a failure to DELETE: a store that refuses removals may + /// still accept a small write, and if it refuses that too this is no worse + /// than what came before. + static final String ERASURE = "CN1$InviteErasureOwed"; + // Entries leave this queue when the server acknowledges them, so the cap is // a safety ceiling rather than a working limit -- and it was far too low // for that. A dropped registration is not recoverable: the code carries no diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 09e2e450cd4..f7bb3e07dd3 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1048,6 +1048,15 @@ public static void reset() { // would block a device that has no invite data to block over. if (anythingSurvives()) { erasurePending = true; + // And durably, because the flag above is a static. A reset + // whose deletes failed and whose process then exited left + // nothing to retry from, and a plain reset keeps the client + // id -- so the provider sees no identity change on the next + // launch and does not erase either. The surviving records came + // back and were transmitted. + Map owed = new LinkedHashMap(); + owed.put("at", String.valueOf(System.currentTimeMillis())); + InviteStore.write(InviteStore.ERASURE, owed); } } } @@ -1137,6 +1146,13 @@ static int currentLookupEpochForTest() { return lookupEpoch; } + // Package private test seam: models the next process, where nothing in + // memory remembers that an erasure was owed. + static void forgetErasurePendingForTest() { + erasurePending = false; + dimensionsReconciled = false; + } + // Package private test seam: lets a test model the next launch, where the // reconciliation runs again. static void forgetDimensionReconciliationForTest() { @@ -1233,6 +1249,9 @@ static boolean eraseInternal() { state = STATE_NONE_FOUND; stateLoaded = true; erasurePending = false; + // The durable marker goes with the flag, or every later launch would + // erase again and settle a fresh install as terminal. + InviteStore.delete(InviteStore.ERASURE); return true; } @@ -1358,6 +1377,7 @@ static void onConsentChanged(boolean allowed) { // catalog's prefix and put a Play dependency and an API floor on every // application that logs a single event. private static void ensureProvider() { + resumeOwedErasure(); reconcileDimensions(); try { List providers = Analytics.getProviders(); @@ -2829,6 +2849,23 @@ private static void writeDimensions(InviteAttribution a) { /// gone and the dimensions are not, the dimensions are the stale copy, and /// the erasure finishes here instead -- on the next launch rather than the /// failing one, which is the best any unverifiable store allows. + /// Picks up an erasure that a previous process could not finish. + /// + /// Called on the same once-per-process path as the dimension + /// reconciliation, and before anything can read or transmit a record: the + /// marker means the records on the disk are ones the user asked to be rid + /// of. + private static void resumeOwedErasure() { + Map owed = InviteStore.read(InviteStore.ERASURE); + if (owed == null || owed.isEmpty()) { + return; + } + erasurePending = true; + if (eraseInternal()) { + InviteStore.delete(InviteStore.ERASURE); + } + } + private static void reconcileDimensions() { if (dimensionsReconciled) { return; @@ -3011,7 +3048,23 @@ private static void notifyUnavailable(String reason) { // // Not persisted: a process that dies with requests outstanding should // retry them, and an empty set on the next launch is what makes it. - private static final List inFlight = new ArrayList(); + private static final Map inFlight = new LinkedHashMap(); + + /// How long an entry stays skippable after its request goes out. + /// + /// The mark exists to stop one burst of invites reposting the whole queue, + /// and a burst happens inside milliseconds -- so a short bound serves that + /// completely while guaranteeing the queue heals. + /// + /// It is a TIME bound rather than a callback because the callback cannot + /// be relied on. These requests are fail-silent, and NetworkManager's + /// fail-silent branch only logs: it never calls handleIOException or + /// handleRuntimeException, so nothing reaches the request's own exception + /// hooks. A transport failure therefore left the entry marked for the life + /// of the process and every automatic drain skipped it -- trading an + /// amplification bug for a registration that only an explicit flush() or a + /// restart would ever resend. + static final long IN_FLIGHT_WINDOW_MS = 60000L; /// Records that a queued registration was evicted to keep the outbox /// under its cap. @@ -3090,6 +3143,22 @@ private static void drainOutbox() { /// answer. true everywhere else, including the flush create() issues /// itself -- that one is what turned a burst of N invites into N(N+1)/2 /// requests, and no invite in a burst needs its predecessors resent. + /// Whether this entry's request went out recently enough to skip. + /// + /// An expired mark is dropped as it is read, so a queue that outlives its + /// requests cleans itself rather than growing for the life of the process. + private static boolean issuedRecently(String json) { + Long at = inFlight.get(json); + if (at == null) { + return false; + } + if (System.currentTimeMillis() - at.longValue() < IN_FLIGHT_WINDOW_MS) { + return true; + } + inFlight.remove(json); + return false; + } + private static void drainOutbox(boolean skipInFlight) { if (!allowed()) { return; @@ -3117,13 +3186,13 @@ private static void drainOutbox(boolean skipInFlight) { // Re-posting an entry that did land is harmless: the server keys on // the code and treats a repeat from the same inviter as idempotent. for (String json : outbox) { - if (skipInFlight && inFlight.contains(json)) { + if (skipInFlight && issuedRecently(json)) { // Already on the wire. Its response will remove it or leave it // for the next drain; sending it again buys nothing and is how // one burst of invites became thousands of requests. continue; } - inFlight.add(json); + inFlight.put(json, Long.valueOf(System.currentTimeMillis())); // The body is rewritten, the KEY is not. The outbox still holds the // original string, and that is what has to be removed when the // server accepts it. diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index 2cf2b5b29f7..f315f1b789f 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -236,12 +236,29 @@ private void deliver(int issued, InstallReferrerClient client, unavailable(issued, callback, Invites.REASON_NO_MATCH); return; } - Preferences.set(PREF_ATTEMPTED, true); if (referrer == null || referrer.length() == 0) { + // Read successfully and there is no invite behind this install. + // Definitive, so the flag is burnt: asking again cannot change it. + Preferences.set(PREF_ATTEMPTED, true); unavailable(issued, callback, Invites.REASON_NO_MATCH); return; } + // The handoff FIRST, the flag after. + // + // Burning the flag before handing the referrer over meant a process + // killed in between lost the exact code for ever: the next launch saw + // isSupported() false and settled the invited install as no-match. + // Invites persists the code inside this call when it runs on the EDT, + // which is the common case. + // + // It is not a guarantee, and saying so is the point: the framework + // marshals onto the EDT, so when this callback arrives on a binder + // thread the persist is queued rather than done, and a process killed + // inside that window still loses it. The window goes from "always" to + // "the callSerially latency", which is the most the SPI shape allows + // without the port knowing what the framework did with the value. referrer(issued, callback, referrer, clickSeconds, beginSeconds); + Preferences.set(PREF_ATTEMPTED, true); } private void finish(int issued, InstallReferrerCallback callback, String reason) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 4ffece5873a..f2316cfaf2b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -627,6 +627,38 @@ void asettledClaimWhosePendingRecordSurvivesIsNotAskedAgain() { "a settled claim was left pending and would be submitted again"); } + @FormTest + void anerasureOwedSurvivesTheProcessThatCouldNotFinishIt() { + // erasurePending is a static. A reset whose deletes failed and whose + // process then exited left nothing to retry from -- and a plain reset + // keeps the client id, so the provider sees no identity change on the + // next launch and does not erase either. The surviving attribution + // came back and was transmitted, which is the one thing reset() + // promises will not happen. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("OWED1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + InviteStore.failNextDeleteForTest(InviteStore.ATTRIBUTION); + Invites.reset(); + assertNotNull(InviteStore.read(InviteStore.ERASURE), + "a failed reset left no durable trace of the erasure it owed"); + + // The next process: nothing in memory remembers, and the store has + // recovered. + Invites.forgetErasurePendingForTest(); + Invites.forgetCachedAttributionForTest(); + Invites.checkForInvite(); + + assertNull(Invites.getAttribution(), + "the erasure was never finished and the attribution came back"); + assertNull(InviteStore.read(InviteStore.ERASURE), + "the marker outlived the erasure it asked for"); + } + @Test @EdtTest void theReferrerCodeIsPersistedBeforeTheClaimGoesOut() { From c67db7d1f99a21de3df1f423e9651aface193529 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:47:15 +0300 Subject: [PATCH 59/70] Invites: warm deep links keep their intent, and an upgrade keeps its dimensions Three review findings, each a case the code got wrong in a way nothing reports. The warm-link path stored a data-less COPY of the intent. That fixed one reader -- an onNewIntent() override reading the intent it was handed -- and broke two others: the documented `android.intent.data` property is published from whatever the activity has stored, and native integrations read getActivity().getIntent().getData(). Both saw a warm deep link as no deep link at all while cold links still carried it. The intent is now stored unmodified and the url is marked delivered by remembering the intent's identity, which suppresses only getAppArg()'s second delivery. Dimension files with no owner stamp are adopted rather than dropped. An absent stamp means the file predates the stamp -- persistDimensions() writes both keys into one preferences record -- and back then setDimension() reserved no prefix and the framework wrote no `cn1_` dimension, so anything with that prefix in such a file is the application's own and dropping it deleted segmentation from an app that never asked for an erasure. A stamp that is present and different is still foreign. The reserved prefix is now documented on setDimension() rather than only on resetClientId(). abandonReplacement() verifies the deletion, like the resolved path already did. Ignoring it left the replacement's PENDING record on disk while memory moved on to RESOLVED, and loadState() prefers a surviving pending record -- so a claim that had already ended definitively was resubmitted every launch, for ever. The second, open-coded copy of that abandonment now calls it. Both behaviour changes are covered by tests verified against the unfixed code; the unused `pending` parameter PMD flagged is gone with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/analytics/Analytics.java | 57 +++++++++++------ .../codename1/analytics/invite/Invites.java | 48 ++++++++++---- .../impl/android/AndroidImplementation.java | 63 ++++++++++++++----- .../analytics/AnalyticsFacadeTest.java | 23 ++++--- .../invite/InviteResilienceTest.java | 25 ++++++++ 5 files changed, 160 insertions(+), 56 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index 0807b1ab887..f2789c919dc 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -371,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 @@ -595,24 +601,34 @@ private static void loadDimensions() { // to their new client id, one launch later and with nothing in memory // left to notice. // - // An ABSENT stamp counts as foreign, not as current. That is the - // difference between a mechanism that works and one that works only - // when the write it depends on succeeded: on a device whose file - // predates the stamp -- or where the storage failure that broke the - // erasure also stopped the stamp being written -- there is nothing to - // compare, and treating that as current is exactly the case being - // defended against. Unknown provenance for a dimension the FRAMEWORK - // owns resolves to dropping it. + // 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 cost of being wrong that way is one re-resolution: a reserved - // dimension dropped here is rewritten by the next attribution. The - // cost of being wrong the other way is an erased identity coming back. + // 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 - // current. It does not read dimensions, so there is no recursion. + // foreign. It does not read dimensions, so there is no recursion. String owner = Preferences.get(PREF_DIMENSIONS_OWNER, null); - boolean foreign = !clientId().equals(owner); + boolean unstamped = owner == null; + boolean foreign = !unstamped && !clientId().equals(owner); String[] rows = split(stored, '\n'); for (String row : rows) { if (row.length() == 0) { @@ -641,10 +657,11 @@ private static void loadDimensions() { } DIMENSIONS.put(key, value); } - if (foreign) { - // Rewritten under the current identity so the drop happens once. - // If this write fails too the next launch simply repeats it, which - // is the correct outcome either way. + 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(); } } @@ -677,7 +694,11 @@ private static void persistDimensions() { // makes a surviving file distinguishable from a current one after a // restart, when nothing in memory remembers that an erasure was asked // for. - Preferences.set(PREF_DIMENSIONS_OWNER, clientId == null ? "" : clientId); + // 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. + Preferences.set(PREF_DIMENSIONS_OWNER, clientId()); } // Replaces the delimiter characters so the persisted form parses back diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index f7bb3e07dd3..3d9af329bbd 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1613,8 +1613,32 @@ private static boolean abandonReplacement() { if (getAttribution() == null) { return false; } - InviteStore.delete(InviteStore.PENDING); - forgetPendingFallback(); + // The removal is VERIFIED, for the same reason the resolved path + // verifies it. Ignoring the result here left the replacement's PENDING + // record on the disk while memory moved on to RESOLVED, and + // loadState() prefers a surviving pending record over the durable + // attribution -- so the next launch resubmitted a claim that had + // already ended definitively, every launch, for ever, with the public + // state reading pending the whole time. + // + // Overwritten with the terminal state when the store will not remove + // it: that says what the deletion would have said, in a record the + // store has just proved it will not delete, and it carries no code and + // no inviter. If that write fails too the held copy is kept and + // retried, rather than being discarded onto a disk that still says + // PENDING. + if (!InviteStore.delete(InviteStore.PENDING)) { + Map settled = new LinkedHashMap(); + settled.put("state", String.valueOf(STATE_RESOLVED)); + if (writePending(settled)) { + forgetPendingFallback(); + } else { + Log.p("invite: an abandoned replacement could not be cleared or marked " + + "settled; the correction is held and retried", Log.WARNING); + } + } else { + forgetPendingFallback(); + } state = STATE_RESOLVED; stateLoaded = true; deferredStarted = false; @@ -2005,7 +2029,7 @@ private static void beginDeferred() { requestReferrer(source); return; } - requestAppClipHandoff(pending); + requestAppClipHandoff(); } private static boolean safeSupported(InstallReferrerSource source) { @@ -2161,7 +2185,7 @@ private static void fallBackToMatchImpl() { if (pending == null) { return; } - requestAppClipHandoff(pending); + requestAppClipHandoff(); } private static void onEdt(Runnable r) { @@ -2192,8 +2216,10 @@ private static void onEdt(Runnable r) { /// fingerprint now, and the code this reads is one the person produced /// themselves by tapping an invite. /// - /// - `pending`: the pending record, for the attempt budget - private static void requestAppClipHandoff(final Map pending) { + /// Takes no pending record: the callback is asynchronous, and a record + /// captured before the call can be stale by the time the answer lands, so + /// it reads `pendingRecord()` at that point instead. + private static void requestAppClipHandoff() { final AppClipHandoffSource source = appClipSource; if (source == null || !source.isSupported()) { // No clip on this platform or this build, which is the ordinary @@ -2633,12 +2659,10 @@ static void handleResolution(String payload, String matchType, boolean deferred, // still with the durable attribution sitting beside it. The // replacement attempt is dropped and the install goes back // to what it was. - InviteStore.delete(InviteStore.PENDING); - forgetPendingFallback(); - state = STATE_RESOLVED; - stateLoaded = true; - deferredStarted = false; - lookupIssuedAt = 0; + // Through abandonReplacement() rather than open-coded: this + // was a second copy of it, and when the deletion there grew + // a verification this copy silently kept the old behaviour. + abandonReplacement(); return; } // Terminal, and it has to be durable. Deleting the record is diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 788d9c8aeff..e0dbb9f819b 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1697,30 +1697,54 @@ static void dispatchNewIntentUrl(Intent intent) { // rather than whatever the previous intent left cached. instance.setAppArg(null); clearIntentProperties(); - // The data is consumed on a COPY, never on the caller's intent. + // The intent is stored UNMODIFIED, and the url is marked as delivered by + // remembering the intent's identity instead of by erasing its data. // - // getAppArg() rebuilds the url from the activity's stored intent, and - // CodenameOneActivity.onStop() clears the app arg -- so leaving the data - // in place meant the next read after a resume rebuilt the same url and - // an application that handles AppArg in start() saw the deep link a - // second time, opening the same invite twice for one tap. + // Two earlier shapes were both wrong. Clearing the data on the intent + // passed in broke the ordinary way to extend onNewIntent() -- + // super.onNewIntent(intent) followed by the subclass reading + // intent.getData(), which had just been nulled underneath it. Storing a + // data-less COPY fixed that one and broke two more readers: the + // documented `android.intent.data` property is published from whatever + // the activity has stored, and native integrations read + // getActivity().getIntent().getData() after onNewIntent(). Both saw a + // warm deep link as no deep link at all while cold links still carried + // it -- an asymmetry an application has no way to work around. // - // Clearing it on the intent passed in was worse. This runs from - // CodenameOneActivity.onNewIntent(), and the ordinary way to extend that - // is super.onNewIntent(intent) followed by the subclass reading - // intent.getData() -- which had just been set to null underneath it, so - // custom deep-link routing that worked before lost the url entirely. The - // copy is what the activity stores; the object the override holds is - // left exactly as the OS handed it over. - android.content.Intent consumed = new android.content.Intent(intent); - consumed.setData(null); - getActivity().setIntent(consumed); + // What actually has to be suppressed is narrower than the data: only + // getAppArg()'s rebuilding of the url from the stored intent, because + // CodenameOneActivity.onStop() clears the app arg and the next read + // after a resume would otherwise report the same deep link a second + // time and open one tapped invite twice. + getActivity().setIntent(intent); + markAppArgDelivered(intent); + // Published here rather than left to getAppArg(), since the properties + // for the previous intent were just cleared and the reader that used to + // repopulate them lazily is exactly the one now suppressed. + publishIntentProperties(getActivity(), intent); Display.getInstance().setProperty("AppArg", data.toString()); } catch (Throwable t) { com.codename1.io.Log.e(t); } } + /// Identity of the intent whose url [#dispatchNewIntentUrl] already delivered as + /// the app arg. Weak because it needs to outlive nothing: the activity holds the + /// intent, and once it stores a different one this reference is free to go. + private static java.lang.ref.WeakReference deliveredAppArgIntent; + + private static void markAppArgDelivered(Intent intent) { + synchronized (intentPropertyLock) { + deliveredAppArgIntent = new java.lang.ref.WeakReference(intent); + } + } + + private static boolean isAppArgDelivered(Intent intent) { + synchronized (intentPropertyLock) { + return deliveredAppArgIntent != null && deliveredAppArgIntent.get() == intent; + } + } + private static void clearIntentProperties() { synchronized (intentPropertyLock) { if (Display.isInitialized()) { @@ -3790,6 +3814,13 @@ public String getAppArg() { intent.removeExtra(Intent.EXTRA_TEXT); Uri u = intent.getData(); String scheme = intent.getScheme(); + if (u != null && isAppArgDelivered(intent)) { + // dispatchNewIntentUrl() already handed this url over as the app arg + // on the warm path. The data stays on the intent for the readers that + // want it -- `android.intent.data` above, and native code asking the + // activity for its intent -- and only the second delivery is dropped. + u = null; + } if (u == null && intent.getExtras() != null) { if (intent.getExtras().keySet().contains("android.intent.extra.STREAM")) { try { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java index 0201414d798..c4a942985f5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java @@ -128,22 +128,25 @@ void dimensionsFromTheCurrentIdentityAreKept() { } @FormTest - void anUnstampedFileIsNotTrustedWithFrameworkDimensions() { - // The case a read-back check cannot reach and the stamp only covers if - // it is treated strictly: a file whose stamp was never written -- - // because it predates the stamp, or because the same storage failure - // that broke the erasure also stopped the stamp landing. Treating an - // absent stamp as current is exactly the state being defended against. + void anUnstampedFileIsAdoptedRatherThanDropped() { + // A file written before the stamp existed, which is what every app + // upgrading from an earlier release has. This was briefly treated as + // foreign -- absent provenance resolving to "drop it" -- and that read + // deleted live data: the framework cannot have written a reserved + // dimension into an unstamped file (persistDimensions() stamps in the + // same call, into the same preferences record), and before this + // feature setDimension() accepted every key and reserved no prefix. So + // a `cn1_` key here is the APPLICATION's, and dropping it silently + // destroys segmentation for an app that never asked for an erasure. Analytics.clearProviders(); Analytics.clearDimensions(); Analytics.simulateSurvivingDimensionsForTest( "cn1_campaign\tspring\nplan\tpro", null); Map loaded = Analytics.getDimensions(); - assertNull(loaded.get("cn1_campaign"), - "an unstamped referral was trusted and reloaded"); - assertEquals("pro", loaded.get("plan"), - "the application's own dimension was destroyed with it"); + assertEquals("spring", loaded.get("cn1_campaign"), + "an upgrading app lost a dimension it set under the old contract"); + assertEquals("pro", loaded.get("plan")); } @FormTest diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index f2316cfaf2b..57785f12cbf 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1180,6 +1180,31 @@ void aFailedReplacementPutsTheInstallBackWhereItWas() { "the install came back pending on the next launch"); } + @Test + @EdtTest + void aFailedReplacementWhosePendingRecordSurvivesIsNotAskedAgain() { + // The same abandonment, with a store that refuses to delete the record + // AND refuses the empty overwrite delete() falls back to. Memory moved + // on to RESOLVED and the disk still said PENDING -- which loadState() + // prefers under re-attribution -- so a claim that had already ended + // definitively was resubmitted on every launch, for ever, with the + // public state reading pending throughout. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST4", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND4"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + InviteStore.failNextDeleteForTest(InviteStore.PENDING); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_DIRECT, false); + + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "an abandoned replacement survived on disk and reopened the lookup"); + assertNotNull(Invites.getAttribution(), + "the install lost the attribution it already had"); + } + @Test @EdtTest void aResumedLookupDoesNotAnnounceItselfToAListenerAlreadyTold() { From b19ff95252dea81585375c2d66a04382baac1cfe Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:14:42 +0300 Subject: [PATCH 60/70] Invites: a "not yet" answer is asked again in the same process beginDeferred() runs at most once per process, which is right for the first attempt and wrong for every later one. The lookup is fail-silent, so a request that never answered leaves deferredStarted set with nothing to clear it, and a claim answered with "retry" -- the ordinary state of an invite minted offline, whose registration has not landed yet -- is pending with no attempt outstanding. Either way the documented call-me-from-start() contract did nothing for the rest of the run: the invite resolved on the next cold start, after an onboarding that could have had its payload, its callback and its dimensions. checkForInvite() now re-arms a pending lookup with nothing in flight, which is what flush() already did for the regained-connectivity case. Bounded by lookupInFlight(), so an application that calls it from every form cannot spend the attempt budget faster than one attempt per retry interval, and by the persisted cap and the attribution window beyond that. The contract is documented on the method rather than left to be discovered. The plugin's source-level test asserted the intent-copy shape that the previous commit replaced, and failed build-test (8) and build-linux-jdk8 on exactly that. It now asserts what replaced it: the stored intent keeps its data, the url is marked delivered by identity, and getAppArg() honours the mark. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 37 ++++++++++++++- .../builders/AndroidInviteNewIntentTest.java | 41 +++++++++++----- .../invite/InviteResilienceTest.java | 47 +++++++++++++++++++ 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 3d9af329bbd..be5092dea3f 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -520,6 +520,14 @@ public static InviteListener getInviteListener() { /// Safe and cheap to call on every start; it will not attribute twice and /// will not report twice. /// + /// Call it on every start rather than only the first. A lookup that ended + /// with "not yet" -- the invite exists but the inviter minted it offline + /// and their registration has not reached the service -- is retried here, + /// at most once per retry interval, so an invite that becomes claimable + /// during the session is picked up in the session rather than on the next + /// cold start. [#flush] does the same for an application that knows it has + /// just regained connectivity. + /// /// #### Returns /// /// true when the launch argument carried an invite link @@ -567,7 +575,7 @@ public static boolean checkForInvite() { } } if (!consumed) { - beginDeferred(); + resumeDeferred(); } return consumed; } @@ -1878,6 +1886,33 @@ private static Map pendingRecord() { return pending; } + // beginDeferred() runs at most once per process, which is right for the + // FIRST attempt and wrong for every later one: the lookup is fail-silent, + // so a request that never answered leaves deferredStarted set with nothing + // to clear it, and a claim answered with "retry" -- the ordinary state of + // an invite minted offline, whose registration has not landed yet -- is + // pending with no attempt outstanding. Either way the documented + // call-me-from-start() contract did nothing at all for the rest of the + // process: the invite resolved on the next cold start, after an onboarding + // that could have had its payload. + // + // Bounded by lookupInFlight(), so an application that calls + // checkForInvite() from every form cannot spend the attempt budget faster + // than one attempt per lookupRetryDelay, and by the persisted attempt cap + // and the attribution window beyond that. + // + // The epoch is bumped for the reason flush() bumps it: the retry + // supersedes whatever the last attempt left outstanding, and without it an + // answer still on the wire can land after the retry resolved and overwrite + // an exact attribution with a statistical one. + private static void resumeDeferred() { + if (deferredStarted && getState() == STATE_PENDING && !lookupInFlight()) { + lookupEpoch++; + deferredStarted = false; + } + beginDeferred(); + } + private static void beginDeferred() { if (deferredStarted) { return; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java index 7752eafe791..592b2be7cc6 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java @@ -97,24 +97,43 @@ void theOverrideRunsOnTheEventDispatchThread() throws IOException { } @Test - void theConsumedUrlIsClearedOnAcopyNotOnTheCallersIntent() throws IOException { + void theDeliveredUrlIsMarkedRatherThanErased() throws IOException { // dispatchNewIntentUrl runs from CodenameOneActivity.onNewIntent, and - // the ordinary way to extend that is super.onNewIntent(intent) followed - // by the subclass reading intent.getData(). Clearing the data on THAT - // object set it to null underneath the override, so custom deep-link - // routing that worked before lost the url entirely. + // every reader of that intent has to survive it. + // + // Clearing the data on the caller's intent broke the ordinary way to + // extend onNewIntent -- super.onNewIntent(intent) followed by the + // subclass reading intent.getData(), which had just been nulled + // underneath it. Storing a data-less COPY fixed that one and broke two + // more: the documented `android.intent.data` property is published from + // whatever the activity has stored, and native integrations read + // getActivity().getIntent().getData(). Both saw a warm deep link as no + // deep link at all while cold links still carried it. + // + // So the intent is stored as it arrived and the url is marked + // delivered by identity, which suppresses the one thing that actually + // had to be suppressed: getAppArg() rebuilding the url from the stored + // intent after onStop() cleared the app arg, and opening one tapped + // invite twice. File port = new File(ANDROID_PORT); assertTrue(port.isFile(), "the port must be readable: " + port.getAbsolutePath()); String source = new String(Files.readAllBytes(port.toPath()), StandardCharsets.UTF_8); int at = source.indexOf("static void dispatchNewIntentUrl("); assertTrue(at > 0, "dispatchNewIntentUrl is gone"); String block = source.substring(at, source.indexOf("\n }", at)); - assertTrue(!block.contains("intent.setData(null)"), - "the caller's intent is mutated, so a subclass reading it after " - + "super.onNewIntent() finds no data"); - assertTrue(block.contains("new android.content.Intent(intent)") - && block.contains("consumed.setData(null)"), - "the url is no longer consumed on a copy"); + assertTrue(!block.contains("setData(null)"), + "the url is erased from an intent again, so a reader of the stored " + + "intent sees a warm deep link as no deep link"); + assertTrue(block.contains("getActivity().setIntent(intent);"), + "the activity no longer stores the intent it was handed"); + assertTrue(block.contains("markAppArgDelivered(intent);"), + "nothing marks the url delivered, so getAppArg() reports it a " + + "second time after a resume"); + assertTrue(block.contains("publishIntentProperties(getActivity(), intent);"), + "the intent properties are not published, and the reader that " + + "used to publish them lazily is the one now suppressed"); + assertTrue(source.contains("isAppArgDelivered(intent)"), + "getAppArg() does not honour the delivered mark"); } @Test diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 57785f12cbf..ab52f5e96d9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -148,6 +148,53 @@ void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { assertNotNull(Invites.getAttribution(), "the late answer was refused"); } + @FormTest + void aNotYetAnswerIsAskedAgainInTheSameProcess() { + // beginDeferred() runs at most once per process, so after a "not yet" + // the documented call-me-from-start() contract did nothing for the rest + // of the run: the request had already completed, no delayed retry + // exists, and deferredStarted stayed set. An invite that became + // claimable seconds later -- the whole point of the offline-mint + // window -- waited for the next cold start, withholding its payload, + // its callback and its dimensions through the entire onboarding. + // + // Driven through the referrer source, because that is the path that + // sets deferredStarted: handleUrl() issues its claim directly and + // leaves the flag alone, so a fixture built on it re-enters + // beginDeferred() either way and cannot tell the two behaviours apart. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.lookupRetryDelay = 0L; + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=RETRY1", 0L, 0L); + } + }); + try { + Invites.checkForInvite(); + assertTrue(implementation.getQueuedRequests().size() > 0, + "the fixture never issued a first claim, so it proves nothing"); + Invites.handleResolution("{\"resolved\":false,\"retry\":true}", + Invites.MATCH_REFERRER, true); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a not-yet answer was treated as a final no"); + implementation.clearQueuedRequests(); + + // The next start, or the next form: the same call the application + // already makes, and the only one it is told to make. + Invites.checkForInvite(); + + assertTrue(implementation.getQueuedRequests().size() > 0, + "a not-yet answer was never asked again in this process"); + } finally { + Invites.lookupRetryDelay = 30000L; + } + } + @Test @EdtTest void aPlainNoIsStillFinalEvenBesideTheRetryAnswer() { From 44b972c821a7b6eb09efe9588f1157ae6d947029 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:40:08 +0300 Subject: [PATCH 61/70] Invites: the durable attribution is the authority for its dimensions too reconcileDimensions() only ever reconciled one way. It dropped reserved dimensions with no record behind them, and accepted whatever the dimensions said whenever a record existed -- so it never noticed the opposite failure. Preferences.set swallows its write failure, so a resolve can commit the attribution and fail to persist the four dimensions: correct in memory for the rest of that process, and gone on the next launch. Every later batch then carried no campaign at all, or -- under re-attribution, where the previous invite's values are still on the disk -- the campaign the install no longer belonged to, crediting its revenue to the wrong cohort. Nothing looked again, because the only thing that could have was satisfied by the record existing. The record is the half that can report whether it was written, so it is the authority: when the persisted dimensions disagree with it they are rewritten from it. Compared first, so an ordinary launch does not pay for a storage write it has no use for. Verified against the unfixed code, where the test sees the previous campaign survive a resolve that replaced it. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 42 ++++++++++++++++++- .../invite/InviteResilienceTest.java | 33 +++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index be5092dea3f..83f00ac3904 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2931,7 +2931,28 @@ private static void reconcileDimensions() { } dimensionsReconciled = true; try { - if (readAttribution() != null) { + InviteAttribution durable = readAttribution(); + if (durable != null) { + // The record stands, so the dimensions are rewritten FROM it + // rather than merely accepted. Reconciliation is two-sided: + // dimensions with no record behind them are stale and go, and a + // record whose dimensions disagree is the authority, because it + // is the half that can report whether it was written. + // + // Preferences cannot. A resolve that committed the attribution + // and then failed to persist the dimensions looked complete -- + // the values were right in memory for the rest of that process + // -- and the next launch loaded whatever the disk still held: + // nothing, so the campaign went missing from every batch, or + // under re-attribution the PREVIOUS invite's values, so revenue + // was credited to a campaign the install no longer belonged to. + // Nothing ever looked again. + // + // Written only when they actually differ, so an ordinary launch + // does not pay for a storage write it has no use for. + if (dimensionsDisagree(durable)) { + writeDimensions(durable); + } return; } // getDimensions() returns a fresh copy and never null, so there @@ -2952,6 +2973,25 @@ private static void reconcileDimensions() { } } + // Whether the persisted dimensions say something other than the record. + // A null on the record means the dimension should be absent, which is what + // writeDimensions() does with it, so the comparison treats absent and null + // as the same answer. + private static boolean dimensionsDisagree(InviteAttribution a) { + Map set = Analytics.getDimensions(); + return differs(set.get(DIMENSION_CODE), a.getCode()) + || differs(set.get(DIMENSION_CAMPAIGN), a.getCampaign()) + || differs(set.get(DIMENSION_CHANNEL), a.getChannel()) + || differs(set.get(DIMENSION_MATCH), a.getMatchType()); + } + + private static boolean differs(String persisted, String durable) { + if (durable == null || durable.length() == 0) { + return persisted != null && persisted.length() > 0; + } + return !durable.equals(persisted); + } + private static void clearDimensions() { for (String dimension : DIMENSIONS) { Analytics.clearDimension(dimension); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index ab52f5e96d9..8aa450b1030 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -148,6 +148,39 @@ void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { assertNotNull(Invites.getAttribution(), "the late answer was refused"); } + @FormTest + void dimensionsAreRestoredFromTheDurableAttribution() { + // Preferences.set swallows its write failure, so a resolve can commit + // the attribution and fail to persist the four dimensions: right in + // memory for the rest of that process, and gone on the next launch. + // Reconciliation only looked one way -- it dropped dimensions with no + // record behind them -- so an attribution whose dimensions were missing + // or, under re-attribution, still the PREVIOUS invite's was accepted + // for ever, and every later batch credited a campaign the install no + // longer belonged to. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("DIMS1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + // The state a failed dimension write leaves behind: the durable record + // says one thing and the dimensions say another. + Analytics.setDimension("cn1_campaign", "the-previous-campaign"); + Analytics.clearDimension("cn1_invite_code"); + + // The next process. + Invites.forgetDimensionReconciliationForTest(); + Invites.forgetCachedAttributionForTest(); + Invites.checkForInvite(); + + assertEquals("spring", Analytics.getDimensions().get("cn1_campaign"), + "a stale campaign outlived the attribution that disagreed with it"); + assertEquals("DIMS1", Analytics.getDimensions().get("cn1_invite_code"), + "the code was never restored from the durable record"); + } + @FormTest void aNotYetAnswerIsAskedAgainInTheSameProcess() { // beginDeferred() runs at most once per process, so after a "not yet" From fde7e2781fa2279f3cf9a3748e7bcab8386726b4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:08:45 +0300 Subject: [PATCH 62/70] Invites: an erasure reaches the registration already on the wire create() hands the registration json to NetworkManager and returns, so an erasure a moment later has two copies to deal with and found only one. Deleting the outbox does not touch a request already queued, and the epoch reset() bumps guards attribution RESPONSES -- a registration never reads it. So a mint from seconds earlier went on to transmit the old client id, the campaign and the payload after the erasure had reported success, which is exactly the identity the user asked to be rid of. Outstanding registrations are tracked and killed at the top of the erasure. NetworkManager skips a killed request when it reaches the front of the queue and kills the connection outright if it is already being sent, so the one case that matters -- queued and unsent -- needs nothing else. The collection is a Vector because it is genuinely touched from two threads: queued on the EDT, released from the network thread, the same boundary the in-flight map already straddles. getAttribution() is gated on the erasure settling, and that half is belt and braces rather than a leak being closed -- said in the code, because the line reads like more than it is. The review round that asked for it argued the record survives on disk and conversion() would emit the erased code under the new client id. Measured instead: ensureProvider() runs resumeOwedErasure() on every call and that retry clears the in-memory copy first, so the facade already answers null with the record demonstrably still on the disk. The test written for it passed against the unfixed code and was deleted rather than kept; the gate stays because it makes the rule true by construction instead of by the order two other methods happen to run in. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 78 ++++++++++++++++++- .../invite/InviteResilienceTest.java | 35 +++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 83f00ac3904..839214f4b50 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -702,6 +702,28 @@ public static boolean handleUrl(String url) { /// the attribution public static InviteAttribution getAttribution() { ensureProvider(); + // Gated like every other read of the durable records, and BELT AND + // BRACES rather than a leak being closed -- worth saying, because the + // obvious reading of this line overstates what it does. + // + // A review round argued that an erasure whose delete failed leaves the + // record on the disk, so this would reload it and conversion() would + // emit the erased code under the new client id. Measured rather than + // assumed: it does not, today. ensureProvider() above runs + // resumeOwedErasure() on every call, and the retry it makes clears the + // in-memory copy and marks it loaded before anything here reads the + // disk -- so the facade already answers null in that state, with the + // record demonstrably still on the disk. That is how the test written + // for it passed against the UNFIXED code, which is why there is no + // test beside this comment. + // + // The gate stays because it makes the rule true by construction rather + // than by the order two other methods happen to run in: a record whose + // deletion is still owed is not readable through this accessor. It + // costs one flag test on the uninvited path. + if (!settleErasure()) { + return null; + } loadAttribution(); return resolved; } @@ -1104,6 +1126,27 @@ private static boolean anythingSurvives() { /// true when nothing readable is left behind static boolean resetVerified() { lookupEpoch++; + // The queue first, because the disk is not the only place a + // pre-erasure registration lives. create() hands the json to + // NetworkManager and returns; deleting the outbox afterwards does not + // touch a request already queued, and the epoch bumped above guards + // only attribution RESPONSES -- a registration never reads it. So a + // mint from a moment ago went on to transmit the old client id, the + // campaign and the payload after the erasure had reported success. + // + // kill() is enough for the case that matters: NetworkManager skips a + // killed request when it reaches the front of the queue, and kills the + // connection outright if it is already being sent. + while (!outstandingRegistrations.isEmpty()) { + InviteConnection req = outstandingRegistrations.elementAt(0); + outstandingRegistrations.removeElementAt(0); + try { + req.kill(); + } catch (Throwable t) { + Log.e(t); + } + } + inFlight.clear(); boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); // ATTRIBUTION names the inviter, and the OUTBOX is the queued @@ -2476,6 +2519,9 @@ private static void send(String url, String json, String outboxKey, String match req.setContentType("application/json"); req.setRequestBody(json); req.setFailSilently(true); + if (registration) { + outstandingRegistrations.addElement(req); + } NetworkManager.getInstance().addToQueue(req); } catch (Throwable t) { Log.e(t); @@ -2516,6 +2562,14 @@ protected void handleErrorResponseCode(int code, String message) { failed = true; } + // Package private for the same reason isFailed() is: ConnectionRequest + // keeps isKilled() protected, so only a subclass can answer it, and + // whether an erasure really stopped a queued registration is exactly + // the kind of thing that must be asserted rather than assumed. + boolean killedForTest() { + return isKilled(); + } + // Package private so a test can drive the outcome this class exists to // get right without standing up a server. boolean isFailed() { @@ -2539,8 +2593,11 @@ protected void handleException(Exception err) { } private void releaseInFlight() { - if (registration && outboxEntry != null) { - inFlight.remove(outboxEntry); + if (registration) { + outstandingRegistrations.removeElement(this); + if (outboxEntry != null) { + inFlight.remove(outboxEntry); + } } } @@ -3149,6 +3206,23 @@ private static void notifyUnavailable(String reason) { // retry them, and an empty set on the next launch is what makes it. private static final Map inFlight = new LinkedHashMap(); + // Registration requests handed to NetworkManager and not yet answered. + // + // An erasure has to reach these. reset() deletes the outbox and bumps the + // epoch, but a request already queued carries its OWN copy of the json -- + // the old client id, the campaign, the payload -- and the epoch guards only + // attribution responses, which a registration is not. So a queued mint + // transmitted a pre-erasure registration after the erasure reported + // success, which is precisely the identity the user asked to be rid of. + // + // A Vector because these are touched from two threads: added on the EDT + // when the request is queued, removed from the network thread when it + // fails. That is the same boundary the map above already straddles, and it + // is a real one -- not the single-threaded EDT the rest of this class runs + // on. + private static final java.util.Vector outstandingRegistrations = + new java.util.Vector(); + /// How long an entry stays skippable after its request goes out. /// /// The mark exists to stop one burst of invites reposting the whole queue, diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 8aa450b1030..6b0765ce89c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -148,6 +148,41 @@ void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { assertNotNull(Invites.getAttribution(), "the late answer was refused"); } + @FormTest + void anErasureKillsARegistrationItCannotCatchOnTheDisk() { + // create() hands the registration json to NetworkManager and returns, + // so an erasure a moment later has two copies to deal with and used to + // find only one: deleting the outbox does not touch a request already + // queued, and the epoch reset() bumps guards attribution RESPONSES, + // which a registration is not. The queued mint went on to transmit the + // old client id, the campaign and the payload after the erasure had + // reported success. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + implementation.clearQueuedRequests(); + + assertNotNull(Invites.create(InviteRequest.create().campaign("spring").build()), + "minting is offline and must still work"); + java.util.List queued = + implementation.getQueuedRequests(); + assertTrue(queued.size() > 0, "the fixture queued no registration at all"); + + Invites.reset(); + + int checked = 0; + for (com.codename1.io.ConnectionRequest r : queued) { + if (r instanceof Invites.InviteConnection) { + assertTrue(((Invites.InviteConnection) r).killedForTest(), + "a registration queued before the erasure was still on its way " + + "out with the erased identity in it"); + checked++; + } + } + assertTrue(checked > 0, "no invite request was queued, so nothing was asserted"); + } + @FormTest void dimensionsAreRestoredFromTheDurableAttribution() { // Preferences.set swallows its write failure, so a resolve can commit From eb21b8b00c2f4dc8fbf7076f16aed5791a6fe5ad Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:29:41 +0300 Subject: [PATCH 63/70] Invites: one save for the dimensions, and a bounded set of queued registrations persistDimensions() writes both keys through the batched Preferences.set(Map), which saves once. Preferences.set(String, Object) saves per key, so the two were two serializations with a window between them -- and the comment here claimed they landed together because they share a record, which was simply wrong and is now true instead of assumed. Worth being precise about what that 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, and the "old dimensions under a new owner" state a review round described 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, which loadDimensions() reads as foreign and drops and reconcileDimensions() then restores from the durable record. One save removes the window rather than the consequence. The set of queued registrations is bounded, and that one is a defect this branch introduced two commits ago. Every invite request is fail-silent, and NetworkManager's fail-silent branch only logs a transport failure -- it calls neither handleIOException nor the request's handleException -- so a registration that never reaches the server has no completion to hang cleanup on, and the set would have held every request body a long offline session ever minted. Entries age out after five minutes, far longer than a request can plausibly sit in the queue and short enough to bound the memory, with a hard ceiling of 32 behind that. The same pass prunes the in-flight marks, which leaked the same way for any entry nothing looked at again. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/analytics/Analytics.java | 27 +++++++--- .../codename1/analytics/invite/Invites.java | 51 +++++++++++++++++++ .../invite/InviteResilienceTest.java | 24 +++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index f2789c919dc..306746bf9be 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -689,16 +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()); - // Stamped with the identity these dimensions belong to. This is what - // makes a surviving file distinguishable from a current one after a - // restart, when nothing in memory remembers that an erasure was asked - // for. + // 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. - Preferences.set(PREF_DIMENSIONS_OWNER, clientId()); + Map record = new LinkedHashMap(); + 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 diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 839214f4b50..8451bf611b2 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2520,6 +2520,8 @@ private static void send(String url, String json, String outboxKey, String match req.setRequestBody(json); req.setFailSilently(true); if (registration) { + req.queuedAt = System.currentTimeMillis(); + pruneOutstanding(); outstandingRegistrations.addElement(req); } NetworkManager.getInstance().addToQueue(req); @@ -2540,6 +2542,9 @@ static final class InviteConnection extends ConnectionRequest { // The outbox entry this request carries, so a success can retire // exactly that one rather than the whole queue. private final String outboxEntry; + // When this was handed to NetworkManager, so a request nothing will + // ever call back about can still be let go of. See pruneOutstanding(). + private long queuedAt; private String payload; // Set by the error hook below. ConnectionRequest reads the body of an // error response by default and then runs the ordinary success path @@ -3223,6 +3228,52 @@ private static void notifyUnavailable(String reason) { private static final java.util.Vector outstandingRegistrations = new java.util.Vector(); + // How long a queued registration is remembered for the erasure's sake. + // + // Generous on purpose. The point of remembering one is to kill it if an + // erasure arrives, so pruning early is what would break -- but nothing + // else can free these: every invite request is fail-silent, and + // NetworkManager's fail-silent branch only logs, so a transport failure + // calls neither postResponse() nor handleException() and the entry has no + // completion to hang cleanup on. Five minutes is far longer than a request + // can plausibly sit in the queue and short enough that an offline process + // minting invites cannot accumulate request bodies without bound. + private static final long OUTSTANDING_MAX_AGE_MS = 5L * 60000L; + + // And a hard ceiling, for the same reason the outbox has one: a bound that + // does not depend on a clock being sane. + private static final int MAX_OUTSTANDING = 32; + + // Package private so a test can assert the bound rather than trust it. + static int outstandingRegistrationCountForTest() { + return outstandingRegistrations.size(); + } + + /// Forgets registrations old enough that nothing is coming back for them. + /// + /// The in-flight marks are pruned on the same pass. `issuedRecently()` + /// drops an entry it happens to look at, so a mark whose outbox entry has + /// since been retired was never looked at again and stayed for the life of + /// the process. + private static void pruneOutstanding() { + long now = System.currentTimeMillis(); + for (int i = outstandingRegistrations.size() - 1; i >= 0; i--) { + InviteConnection req = outstandingRegistrations.elementAt(i); + if (now - req.queuedAt >= OUTSTANDING_MAX_AGE_MS) { + outstandingRegistrations.removeElementAt(i); + } + } + while (outstandingRegistrations.size() >= MAX_OUTSTANDING) { + outstandingRegistrations.removeElementAt(0); + } + for (String json : new ArrayList(inFlight.keySet())) { + Long at = inFlight.get(json); + if (at == null || now - at.longValue() >= IN_FLIGHT_WINDOW_MS) { + inFlight.remove(json); + } + } + } + /// How long an entry stays skippable after its request goes out. /// /// The mark exists to stop one burst of invites reposting the whole queue, diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 6b0765ce89c..fe13814910d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -148,6 +148,30 @@ void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { assertNotNull(Invites.getAttribution(), "the late answer was refused"); } + @FormTest + void queuedRegistrationsDoNotAccumulateWithoutBound() { + // Every invite request is fail-silent, and NetworkManager's fail-silent + // branch only LOGS a transport failure -- it calls neither + // handleIOException nor the request's handleException -- so a + // registration that never reaches the server has no completion to hang + // cleanup on. The set that remembers queued registrations for the + // erasure's sake would otherwise hold every request body a long + // offline session ever minted. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + + for (int i = 0; i < 80; i++) { + assertNotNull(Invites.create(InviteRequest.create().campaign("c" + i).build()), + "minting is offline and must still work"); + } + + assertTrue(Invites.outstandingRegistrationCountForTest() <= 32, + "queued registrations accumulated without bound: " + + Invites.outstandingRegistrationCountForTest()); + } + @FormTest void anErasureKillsARegistrationItCannotCatchOnTheDisk() { // create() hands the registration json to NetworkManager and returns, From 1802b966efa483767055a256b7ea8d2d64be6aaf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:56:57 +0300 Subject: [PATCH 64/70] Invites: the App Clip was never built on the default path, and four more The clip target is created inside the needsXcodeProjectMutation block, and the gate did not include inviteAppClipTargetWanted. An invite-enabled app that uses no pods and no other extension -- the DEFAULT shape of an app that has just switched invites on -- therefore built and shipped with no clip at all, and every iOS install would have settled as no_match for ever. Nothing reports it: the app signs, the association file lists the clip, and the clip does not exist. The global deployment-target pass skips app-extension targets, and a clip is not one -- its product type is a full application bundle. fix_xcode_schemes.rb runs again after pods integration, so the second pass rewrote the clip to the host app's deployment target, commonly below 14, while the guard that stops the target being created twice also skipped restoring its floor. An App Clip below iOS 14 does not launch. On Android, a superseded referrer callback burnt the one-shot flag. The delivery methods drop it on purpose when a newer exchange has taken over, but the flag was set regardless -- so if that newer exchange then failed transiently, every later launch saw isSupported() false and the exact Play referrer was gone for an install that really had one. Only the exchange that answered burns it now. An erasure whose durable marker survives is no longer reported done. The marker outliving a successful erasure is read by the next ensureProvider() as an erasure still owed, and eraseInternal() then runs again -- against the invite the person accepted after the reset, and the registration they minted. Reporting it incomplete keeps the flag and the marker in agreement, so the gate stays shut and there is nothing new to destroy. And pruneOutstanding() kills what it drops. Bounding the set of queued registrations two commits ago removed the only handle reset() has for cancelling one, so a pruned request could still transmit its pre-erasure identity. Killing it costs nothing: the durable outbox is what gets a registration sent in the end. All five have tests verified against the unfixed code; the two builder ones assert the source text, as the other builder-assembly tests here do. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 51 +++++++++-- .../referrer/AndroidInstallReferrer.java | 38 +++++--- .../com/codename1/builders/IPhoneBuilder.java | 22 +++++ .../InviteAppClipProjectMutationTest.java | 87 +++++++++++++++++++ .../invite/InviteResilienceTest.java | 65 ++++++++++++++ 5 files changed, 248 insertions(+), 15 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 8451bf611b2..5c0367b4fec 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1299,10 +1299,27 @@ static boolean eraseInternal() { } state = STATE_NONE_FOUND; stateLoaded = true; - erasurePending = false; // The durable marker goes with the flag, or every later launch would - // erase again and settle a fresh install as terminal. - InviteStore.delete(InviteStore.ERASURE); + // erase again and settle a fresh install as terminal -- and the result + // is CHECKED, because ignoring it made the two disagree in the one + // direction that destroys data. + // + // A marker that outlives a successful erasure is read by the next + // ensureProvider() as an erasure still owed, and eraseInternal() runs + // again -- against whatever the person has done since. An invite they + // accepted after the reset, a registration they minted, both gone, on + // every launch until the marker can be written. Reporting the erasure + // incomplete instead keeps the flag and the marker saying the same + // thing: the gate stays closed, so there is nothing new to destroy, + // and the retry costs an erasure that has nothing left to erase. + if (!InviteStore.delete(InviteStore.ERASURE)) { + Log.p("invite: the erasure is done but its marker could not be cleared, so it " + + "is reported incomplete and retried rather than repeated against " + + "whatever comes next", Log.WARNING); + erasurePending = true; + return false; + } + erasurePending = false; return true; } @@ -3244,6 +3261,30 @@ private static void notifyUnavailable(String reason) { // does not depend on a clock being sane. private static final int MAX_OUTSTANDING = 32; + /// Drops a remembered registration, and KILLS it on the way out. + /// + /// Forgetting one without killing it was a hole in the erasure this set + /// exists for: the reference is the only handle reset() has, so a request + /// pruned while still queued became invisible to the kill sweep and + /// NetworkManager could transmit its pre-erasure client id, campaign and + /// payload after reset() had reported success. + /// + /// Killing what is dropped costs nothing that matters. A request old + /// enough to be pruned has almost certainly gone already -- kill() on a + /// finished request does nothing -- and one that really is still queued is + /// wedged behind a stalled network, where its own durable outbox entry is + /// the thing that gets it sent in the end. The registration is not lost by + /// killing it; the next drain re-queues it. + private static void forget(int index) { + InviteConnection req = outstandingRegistrations.elementAt(index); + outstandingRegistrations.removeElementAt(index); + try { + req.kill(); + } catch (Throwable t) { + Log.e(t); + } + } + // Package private so a test can assert the bound rather than trust it. static int outstandingRegistrationCountForTest() { return outstandingRegistrations.size(); @@ -3260,11 +3301,11 @@ private static void pruneOutstanding() { for (int i = outstandingRegistrations.size() - 1; i >= 0; i--) { InviteConnection req = outstandingRegistrations.elementAt(i); if (now - req.queuedAt >= OUTSTANDING_MAX_AGE_MS) { - outstandingRegistrations.removeElementAt(i); + forget(i); } } while (outstandingRegistrations.size() >= MAX_OUTSTANDING) { - outstandingRegistrations.removeElementAt(0); + forget(0); } for (String json : new ArrayList(inFlight.keySet())) { Long at = inFlight.get(json); diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index f315f1b789f..8ead7411f2b 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -239,8 +239,11 @@ private void deliver(int issued, InstallReferrerClient client, if (referrer == null || referrer.length() == 0) { // Read successfully and there is no invite behind this install. // Definitive, so the flag is burnt: asking again cannot change it. - Preferences.set(PREF_ATTEMPTED, true); - unavailable(issued, callback, Invites.REASON_NO_MATCH); + // + // Only when THIS exchange still owns the answer -- see burn(). + if (unavailable(issued, callback, Invites.REASON_NO_MATCH)) { + Preferences.set(PREF_ATTEMPTED, true); + } return; } // The handoff FIRST, the flag after. @@ -257,13 +260,26 @@ private void deliver(int issued, InstallReferrerClient client, // inside that window still loses it. The window goes from "always" to // "the callSerially latency", which is the most the SPI shape allows // without the port knowing what the framework did with the value. - referrer(issued, callback, referrer, clickSeconds, beginSeconds); - Preferences.set(PREF_ATTEMPTED, true); + if (referrer(issued, callback, referrer, clickSeconds, beginSeconds)) { + Preferences.set(PREF_ATTEMPTED, true); + } } + /// The one-shot flag is burnt by the exchange that ANSWERED, and only by + /// it. + /// + /// A bind that outlives the retry interval leaves its callback pending + /// while a later checkForInvite() starts a fresh exchange. When the first + /// one finally lands it is superseded -- `issued != attemptSeq` -- and both + /// delivery methods below drop it on purpose, because the newer exchange + /// owns the outcome. Burning the flag anyway performed the one side effect + /// that cannot be undone: if the newer exchange then failed transiently, + /// every later launch saw isSupported() as false and the exact Play + /// referrer was gone, for an install that really did have one. private void finish(int issued, InstallReferrerCallback callback, String reason) { - Preferences.set(PREF_ATTEMPTED, true); - unavailable(issued, callback, reason); + if (unavailable(issued, callback, reason)) { + Preferences.set(PREF_ATTEMPTED, true); + } } /// Reports "no referral", at most once. @@ -271,21 +287,23 @@ private void finish(int issued, InstallReferrerCallback callback, String reason) /// Every terminal path goes through here so the disconnect handler can /// close an exchange nobody else closed without risking a second answer /// for one that somebody did. - private void unavailable(int issued, InstallReferrerCallback callback, String reason) { + private boolean unavailable(int issued, InstallReferrerCallback callback, String reason) { if (answered || issued != attemptSeq) { - return; + return false; } answered = true; callback.onUnavailable(reason); + return true; } - private void referrer(int issued, InstallReferrerCallback callback, String value, + private boolean referrer(int issued, InstallReferrerCallback callback, String value, long clickSeconds, long beginSeconds) { if (answered || issued != attemptSeq) { - return; + return false; } answered = true; callback.onReferrer(value, clickSeconds, beginSeconds); + return true; } private void close(InstallReferrerClient client) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 94c4059ffd0..73e9e6ee59b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6932,11 +6932,21 @@ public void usesClassMethod(String cls, String method) { } // Wallet/widget extensions and .ios.appext archives mutate the Xcode project through // the ruby xcodeproj gem even when CocoaPods isn't otherwise needed. + // + // The App Clip belongs in this list, and leaving it out was worth a + // whole broken feature: the target is created inside this block, so + // an invite-enabled app that uses no pods and no other extension -- + // which is the DEFAULT shape of an app that just switched invites + // on -- built and shipped with no clip at all. Nothing reports it. + // The app signs, the association file lists it, and every iOS + // install settles as no_match for ever, because the clip that was + // supposed to hand the code over does not exist. boolean needsXcodeProjectMutation = runPods || walletExtensionEnabled || surfacesExtensionEnabled || matterExtensionEnabled || callDirectoryExtensionEnabled || vpnTunnelBuilder.isEnabled() || documentProviderEnabled + || inviteAppClipTargetWanted || hasAppExtensionArchives(appExtensionArchiveDir); if (needsXcodeProjectMutation) { try { @@ -6981,6 +6991,18 @@ public void usesClassMethod(String cls, String method) { + " # pass stomps them down to the app's deployment target (seen as WidgetKit\n" + " # sources compiling at iOS 14 instead of the extension's 16.1).\n" + " next if target.respond_to?(:product_type) && target.product_type == 'com.apple.product-type.app-extension'\n" + // And the App Clip, which is not an app-extension: its product + // type is a full application bundle, so the skip above never + // matched it. Appending the clip's own settings after this pass + // covers the FIRST run only -- the script re-runs after pods + // integration, and on the second pass the target already exists, + // so the guard that stops it being created twice also skips the + // block that would restore its floor. This pass then left the + // clip at the app's deployment target, commonly below 14, and + // an App Clip built below 14 does not launch. + + " next if target.respond_to?(:product_type) && target.product_type == '" + + InviteAppClipBuilder.PRODUCT_TYPE + "'\n" + + "" + " target.build_configurations.each do |config|\n" + " config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '" + getDeploymentTarget(request) + "'\n" + simulatorArchitectureSettings diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java new file mode 100644 index 00000000000..bdcb75edeaf --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java @@ -0,0 +1,87 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The App Clip has to survive the two things the Xcode project script does to + * every target, and neither failure says anything at build time. + * + *

Asserted against the builder's source text, as {@code StubLifecycleCastTest} + * and {@code AndroidInviteNewIntentTest} do: both properties are of an inline + * assembly a few hundred lines long with no seam to call, and the cost of + * getting either wrong is a clip that is simply absent or does not launch -- + * with an app that builds, signs and ships.

+ */ +public class InviteAppClipProjectMutationTest { + + private static final String BUILDER = + "src/main/java/com/codename1/builders/IPhoneBuilder.java"; + + private String source() throws IOException { + File builder = new File(BUILDER); + assertTrue(builder.isFile(), "the builder must be readable: " + builder.getAbsolutePath()); + return new String(Files.readAllBytes(builder.toPath()), StandardCharsets.UTF_8); + } + + @Test + void wantingAclipIsEnoughToMutateTheProject() throws IOException { + // The clip target is created inside the needsXcodeProjectMutation + // block. Without the flag in that condition, an invite-enabled app + // that uses no pods and no other extension -- the DEFAULT shape of an + // app that just switched invites on -- built with no clip at all, and + // every iOS install settled as no_match for ever. + String source = source(); + int at = source.indexOf("boolean needsXcodeProjectMutation ="); + assertTrue(at > 0, "the mutation gate is gone"); + String condition = source.substring(at, source.indexOf(";", at)); + assertTrue(condition.contains("inviteAppClipTargetWanted"), + "an invite-only iOS build does not enter the block that creates its " + + "App Clip, so the clip is never generated or embedded"); + } + + @Test + void theGlobalDeploymentPassLeavesTheClipAlone() throws IOException { + // fix_xcode_schemes.rb runs twice -- again after pods integration -- + // and the global pass rewrites IPHONEOS_DEPLOYMENT_TARGET on every + // target it does not skip. The clip's settings are appended after that + // pass, which covers the first run only: on the second the target + // already exists, so the guard that stops it being created twice also + // skips restoring its floor. An App Clip below iOS 14 does not launch. + String source = source(); + int at = source.indexOf("deploymentTargetStr = \"begin"); + assertTrue(at > 0, "the global deployment-target pass moved"); + String pass = source.substring(at, source.indexOf("rescue => e", at)); + assertTrue(pass.contains("InviteAppClipBuilder.PRODUCT_TYPE"), + "the pass does not skip the App Clip, so a second run drops it to the " + + "app's deployment target"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index fe13814910d..3fc38221992 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -148,6 +148,37 @@ void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { assertNotNull(Invites.getAttribution(), "the late answer was refused"); } + @FormTest + void aPrunedRegistrationIsKilledRatherThanJustForgotten() { + // The set of queued registrations is the only handle reset() has for + // killing one, so forgetting an entry to bound memory made it + // invisible to the erasure -- and NetworkManager would then transmit + // its pre-erasure client id, campaign and payload after reset() had + // reported success. Bounding the set must not create a request nothing + // can cancel. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + implementation.clearQueuedRequests(); + + for (int i = 0; i < 80; i++) { + assertNotNull(Invites.create(InviteRequest.create().campaign("c" + i).build()), + "minting is offline and must still work"); + } + + int pruned = 0; + for (com.codename1.io.ConnectionRequest r : implementation.getQueuedRequests()) { + if (r instanceof Invites.InviteConnection + && ((Invites.InviteConnection) r).killedForTest()) { + pruned++; + } + } + assertTrue(pruned > 0, + "entries were dropped from the set without being killed, so a queued " + + "registration outlived the only thing that could cancel it"); + } + @FormTest void queuedRegistrationsDoNotAccumulateWithoutBound() { // Every invite request is fail-silent, and NetworkManager's fail-silent @@ -172,6 +203,40 @@ void queuedRegistrationsDoNotAccumulateWithoutBound() { + Invites.outstandingRegistrationCountForTest()); } + @FormTest + void anErasureWhoseMarkerSurvivesIsNotReportedDone() { + // The records went and the durable marker did not. Reported as done, + // the marker is read by the next ensureProvider() as an erasure still + // owed -- and eraseInternal() runs again, against whatever the person + // has accepted or minted since, on every launch until the write + // succeeds. Reporting it incomplete keeps the flag and the marker + // saying the same thing, so the gate stays shut and there is nothing + // new to destroy. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("MARKER1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + // The marker exists only once an erasure has been OWED, so the first + // one has to fail: the attribution delete is refused, which is what + // writes it. + InviteStore.failNextDeleteForTest(InviteStore.ATTRIBUTION); + Invites.reset(); + assertNotNull(InviteStore.read(InviteStore.ERASURE), + "the fixture did not leave an erasure owed, so there is no marker"); + + // The store recovers for the records and still refuses the marker. + // eraseInternal() is the level that owns it: resetVerified() below it + // only deletes the records. + InviteStore.failNextDeleteForTest(InviteStore.ERASURE); + assertFalse(Invites.eraseInternal(), + "an erasure whose marker survived was reported complete"); + assertNotNull(InviteStore.read(InviteStore.ERASURE), + "the fixture cleared the marker, so there is nothing to report about"); + } + @FormTest void anErasureKillsARegistrationItCannotCatchOnTheDisk() { // create() hands the registration json to NetworkManager and returns, From 4c3c0ba1c440663931d18015debab162091c2ff0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:23:06 +0300 Subject: [PATCH 65/70] Invites: consent withdrawal cancels what is already queued The epoch decides whether an ANSWER is acted on, and a registration is never answered -- so one queued behind other network work still went out with the client id, the campaign and the payload after consent was withdrawn, which is the transmission the withdrawal exists to prevent. It is killed now, by the same sweep the erasure uses, and the two share it. The durable outbox is left alone on purpose: those entries are what a later grant sends, and withdrawing consent is not a request to forget the invites this person minted. The Android one-shot referrer flag no longer survives into a different installation. Auto-backup is on by default, so a reinstall or a device migration restores this app's files, the flag among them -- and restored, it says the referrer has already been read, so the new installation never asks and its own Play referrer, the one exact answer this path exists for, is thrown away before anything looks at it. firstInstallTime separates the two: it survives an app update, so an ordinary upgrade is not mistaken for a new install. That covers the flag this class owns and NOT the invite records, which the same restore also brings back -- so a migrated device can still report the previous installation's inviter as its own. Fixing that needs a core entry point meaning "new installation: forget the last one but stay attributable", and the one public method that comes close is the erasure, whose terminal marker would leave the new install permanently unattributable -- worse than the problem. iOS has the same exposure through device transfer and no equivalent signal at all. Written down where the detection lives rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 46 ++++++++++--- .../referrer/AndroidInstallReferrer.java | 65 ++++++++++++++++++- .../invite/InviteResilienceTest.java | 36 ++++++++++ 3 files changed, 135 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 5c0367b4fec..7c238651d97 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1137,16 +1137,7 @@ static boolean resetVerified() { // kill() is enough for the case that matters: NetworkManager skips a // killed request when it reaches the front of the queue, and kills the // connection outright if it is already being sent. - while (!outstandingRegistrations.isEmpty()) { - InviteConnection req = outstandingRegistrations.elementAt(0); - outstandingRegistrations.removeElementAt(0); - try { - req.kill(); - } catch (Throwable t) { - Log.e(t); - } - } - inFlight.clear(); + killQueuedRegistrations(); boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); // ATTRIBUTION names the inviter, and the OUTBOX is the queued @@ -1395,6 +1386,17 @@ static void onConsentChanged(boolean allowed) { // end of the window. The epoch bump additionally discards any response // already in flight. lookupEpoch++; + // The epoch stops an ANSWER being acted on; it does not stop a REQUEST + // going out, and a registration is not answered at all. One queued + // behind other network work would have transmitted the client id, the + // campaign and the payload after consent was withdrawn -- the + // transmission the withdrawal exists to prevent, sent by a request that + // was already past every gate when it was queued. + // + // The durable outbox is deliberately left alone: the entries are what a + // later grant sends, and withdrawing consent is not a request to forget + // the invites this person minted. + killQueuedRegistrations(); // Nothing is outstanding once the epoch has moved: any response still // on the wire fails the guard. Saying so here is what lets a later // grant resume immediately rather than waiting out a retry delay for a @@ -3261,6 +3263,30 @@ private static void notifyUnavailable(String reason) { // does not depend on a clock being sane. private static final int MAX_OUTSTANDING = 32; + /// Kills every registration handed to NetworkManager and not yet answered. + /// + /// Shared by the erasure and by a consent withdrawal, which need the same + /// thing for different reasons: one must not transmit an identity the user + /// asked to be rid of, the other must not transmit anything at all. Neither + /// is served by the epoch, which only decides whether an ANSWER is acted + /// on -- a registration is never answered, and a queued request has already + /// passed every gate it will ever pass. + /// + /// The durable outbox is untouched. What is queued is a copy; the outbox is + /// the record, and it is what a later grant sends. + private static void killQueuedRegistrations() { + while (!outstandingRegistrations.isEmpty()) { + InviteConnection req = outstandingRegistrations.elementAt(0); + outstandingRegistrations.removeElementAt(0); + try { + req.kill(); + } catch (Throwable t) { + Log.e(t); + } + } + inFlight.clear(); + } + /// Drops a remembered registration, and KILLS it on the way out. /// /// Forgetting one without killing it was a hole in the erasure this set diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index 8ead7411f2b..d7e77d579f6 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -49,6 +49,10 @@ public class AndroidInstallReferrer implements InstallReferrerSource { // and the flag is what stops a service bind on every launch. private static final String PREF_ATTEMPTED = "cn1$invite$referrerAttempted"; + /// The installation the flag above belongs to. See + /// [#forgetAflagRestoredFromAnotherInstallation]. + private static final String PREF_INSTALL_TIME = "cn1$invite$referrerInstallTime"; + private boolean retried; // Whether the framework has been given its one answer FOR THIS EXCHANGE. @@ -75,8 +79,65 @@ public class AndroidInstallReferrer implements InstallReferrerSource { @Override public boolean isSupported() { - return AndroidNativeUtil.getContext() != null - && !Preferences.get(PREF_ATTEMPTED, false); + Context context = AndroidNativeUtil.getContext(); + if (context == null) { + return false; + } + forgetAflagRestoredFromAnotherInstallation(context); + return !Preferences.get(PREF_ATTEMPTED, false); + } + + /// Clears the one-shot flag when it came from a DIFFERENT installation. + /// + /// Android's auto-backup is on by default -- `AndroidGradleBuilder` leaves + /// `android:allowBackup` alone -- so a reinstall or a device migration + /// restores this app's files, this flag among them. Restored, it says the + /// referrer has already been read, and the new installation never asks: its + /// own Play referrer, which is the one exact answer this whole path + /// exists for, is thrown away before anything looks at it. + /// + /// `firstInstallTime` is what separates the two. It survives an app + /// UPDATE, so an ordinary upgrade is not mistaken for a new install, and a + /// restore into a new installation carries the OLD value in preferences + /// while the package manager reports the new one. Unknown means this code + /// is running for the first time on an install that predates it, which is + /// not evidence of anything and stamps rather than clears. + /// + /// #### What this does NOT cover + /// + /// Only the flag this class owns. A restore also brings back the invite + /// records themselves -- the resolved attribution above all -- so a device + /// migrated from another one can still report the previous installation's + /// inviter as its own. Fixing that needs a core entry point meaning "this + /// is a new installation, forget the last one but stay attributable", and + /// the one public method that comes close, `Invites.reset()`, is the + /// erasure: it writes a terminal marker, which would leave the new install + /// permanently unattributable -- worse than the problem. iOS has the same + /// exposure through device transfer and no equivalent signal here at all. + /// Left as a deliberate gap rather than guessed at. + private void forgetAflagRestoredFromAnotherInstallation(Context context) { + try { + long current = context.getPackageManager() + .getPackageInfo(context.getPackageName(), 0).firstInstallTime; + if (current <= 0L) { + return; + } + long known = Preferences.get(PREF_INSTALL_TIME, 0L); + if (known == 0L) { + Preferences.set(PREF_INSTALL_TIME, current); + return; + } + if (known != current) { + Preferences.set(PREF_INSTALL_TIME, current); + Preferences.set(PREF_ATTEMPTED, false); + } + } catch (Throwable t) { + // A package manager that cannot describe this app's own package is + // not a state to guess in: leaving the flag alone keeps the + // ordinary behaviour rather than re-reading a referrer that may + // genuinely have been consumed. + com.codename1.io.Log.e(t); + } } @Override diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 3fc38221992..f4c50c52cbb 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -237,6 +237,42 @@ void anErasureWhoseMarkerSurvivesIsNotReportedDone() { "the fixture cleared the marker, so there is nothing to report about"); } + @FormTest + void withdrawingConsentKillsARegistrationAlreadyOnItsWay() { + // The epoch decides whether an ANSWER is acted on, and a registration + // is never answered -- so a request queued behind other network work + // went out with the client id, the campaign and the payload after + // consent was withdrawn, which is the transmission the withdrawal + // exists to prevent. The durable outbox is left alone on purpose: those + // entries are what a later grant sends. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + implementation.clearQueuedRequests(); + + assertNotNull(Invites.create(InviteRequest.create().campaign("spring").build()), + "minting is offline and must still work"); + java.util.List queued = + implementation.getQueuedRequests(); + assertTrue(queued.size() > 0, "the fixture queued no registration at all"); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + int checked = 0; + for (com.codename1.io.ConnectionRequest r : queued) { + if (r instanceof Invites.InviteConnection) { + assertTrue(((Invites.InviteConnection) r).killedForTest(), + "a registration queued before the withdrawal was still on its way " + + "out with the data consent was just refused for"); + checked++; + } + } + assertTrue(checked > 0, "no invite request was queued, so nothing was asserted"); + assertFalse(InviteStore.readOutbox().isEmpty(), + "the durable outbox was discarded, so a later grant has nothing to send"); + } + @FormTest void anErasureKillsARegistrationItCannotCatchOnTheDisk() { // create() hands the registration json to NetworkManager and returns, From 86759938c5da6150cbc35b9a5dba25805568cb1a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:42:51 +0300 Subject: [PATCH 66/70] Invites: a queued claim is an erasure's problem too, and codes are compared structurally The kill sweep tracked registrations only. A claim carries the client id AND the code it is claiming -- the same identity the erasure exists to remove -- so one queued behind other network work still transmitted it after reset() reported success. The epoch discards the response; nothing was stopping the request. Every invite request is tracked now, and both the erasure and a consent withdrawal kill all of them. isRegistered() searched for the code anywhere in a queued entry's text, and an entry carries the campaign, the payload, the title and whatever parameters the application set. A referral message quoting another invite's code therefore made a registration that HAD been acknowledged report as still queued, and an application that waits for isRegistered() before sharing waits for ever. The acknowledgement path had the mirror image and it is the worse of the two: it cleared an unrelated invite from the unacknowledged set, so one the server has never seen reported as registered. Both parse the entry and compare its top-level code, which is the same lesson as the associated-domain comparison earlier on this branch: a substring test on structured text answers a different question. Both verified against the unfixed code. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 120 ++++++++++++------ .../invite/InviteResilienceTest.java | 70 +++++++++- 2 files changed, 150 insertions(+), 40 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 7c238651d97..f28f3600c86 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1137,7 +1137,7 @@ static boolean resetVerified() { // kill() is enough for the case that matters: NetworkManager skips a // killed request when it reaches the front of the queue, and kills the // connection outright if it is already being sent. - killQueuedRegistrations(); + killQueuedRequests(); boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); // ATTRIBUTION names the inviter, and the OUTBOX is the queued @@ -1396,7 +1396,7 @@ static void onConsentChanged(boolean allowed) { // The durable outbox is deliberately left alone: the entries are what a // later grant sends, and withdrawing consent is not a request to forget // the invites this person minted. - killQueuedRegistrations(); + killQueuedRequests(); // Nothing is outstanding once the epoch has moved: any response still // on the wire fails the guard. Saying so here is what lets a later // grant resume immediately rather than waiting out a retry delay for a @@ -2538,11 +2538,16 @@ private static void send(String url, String json, String outboxKey, String match req.setContentType("application/json"); req.setRequestBody(json); req.setFailSilently(true); - if (registration) { - req.queuedAt = System.currentTimeMillis(); - pruneOutstanding(); - outstandingRegistrations.addElement(req); - } + // EVERY invite request, not only the registrations. + // + // A claim carries the client id and the invite code, which is the + // same identity an erasure is asked to be rid of -- and tracking + // only registrations left a queued claim free to transmit it after + // reset() had reported success. The epoch discards the response; + // nothing was stopping the request. + req.queuedAt = System.currentTimeMillis(); + pruneOutstanding(); + outstanding.addElement(req); NetworkManager.getInstance().addToQueue(req); } catch (Throwable t) { Log.e(t); @@ -2617,11 +2622,9 @@ protected void handleException(Exception err) { } private void releaseInFlight() { - if (registration) { - outstandingRegistrations.removeElement(this); - if (outboxEntry != null) { - inFlight.remove(outboxEntry); - } + outstanding.removeElement(this); + if (registration && outboxEntry != null) { + inFlight.remove(outboxEntry); } } @@ -3230,24 +3233,27 @@ private static void notifyUnavailable(String reason) { // retry them, and an empty set on the next launch is what makes it. private static final Map inFlight = new LinkedHashMap(); - // Registration requests handed to NetworkManager and not yet answered. + // Invite requests handed to NetworkManager and not yet answered. Claims as + // well as registrations -- every one of them carries the client id. // // An erasure has to reach these. reset() deletes the outbox and bumps the // epoch, but a request already queued carries its OWN copy of the json -- - // the old client id, the campaign, the payload -- and the epoch guards only - // attribution responses, which a registration is not. So a queued mint - // transmitted a pre-erasure registration after the erasure reported - // success, which is precisely the identity the user asked to be rid of. + // the old client id, the code, the campaign, the payload -- and the epoch + // decides only whether an ANSWER is acted on. So a queued mint transmitted + // a pre-erasure registration after the erasure reported success, and when + // only registrations were tracked a queued CLAIM did the same with the + // client id and the code it was claiming. Both are precisely the identity + // the user asked to be rid of. // // A Vector because these are touched from two threads: added on the EDT // when the request is queued, removed from the network thread when it // fails. That is the same boundary the map above already straddles, and it // is a real one -- not the single-threaded EDT the rest of this class runs // on. - private static final java.util.Vector outstandingRegistrations = + private static final java.util.Vector outstanding = new java.util.Vector(); - // How long a queued registration is remembered for the erasure's sake. + // How long a queued request is remembered for the erasure's sake. // // Generous on purpose. The point of remembering one is to kill it if an // erasure arrives, so pruning early is what would break -- but nothing @@ -3263,7 +3269,7 @@ private static void notifyUnavailable(String reason) { // does not depend on a clock being sane. private static final int MAX_OUTSTANDING = 32; - /// Kills every registration handed to NetworkManager and not yet answered. + /// Kills every invite request handed to NetworkManager and not yet answered. /// /// Shared by the erasure and by a consent withdrawal, which need the same /// thing for different reasons: one must not transmit an identity the user @@ -3274,10 +3280,10 @@ private static void notifyUnavailable(String reason) { /// /// The durable outbox is untouched. What is queued is a copy; the outbox is /// the record, and it is what a later grant sends. - private static void killQueuedRegistrations() { - while (!outstandingRegistrations.isEmpty()) { - InviteConnection req = outstandingRegistrations.elementAt(0); - outstandingRegistrations.removeElementAt(0); + private static void killQueuedRequests() { + while (!outstanding.isEmpty()) { + InviteConnection req = outstanding.elementAt(0); + outstanding.removeElementAt(0); try { req.kill(); } catch (Throwable t) { @@ -3287,7 +3293,7 @@ private static void killQueuedRegistrations() { inFlight.clear(); } - /// Drops a remembered registration, and KILLS it on the way out. + /// Drops a remembered request, and KILLS it on the way out. /// /// Forgetting one without killing it was a hole in the erasure this set /// exists for: the reference is the only handle reset() has, so a request @@ -3302,8 +3308,8 @@ private static void killQueuedRegistrations() { /// the thing that gets it sent in the end. The registration is not lost by /// killing it; the next drain re-queues it. private static void forget(int index) { - InviteConnection req = outstandingRegistrations.elementAt(index); - outstandingRegistrations.removeElementAt(index); + InviteConnection req = outstanding.elementAt(index); + outstanding.removeElementAt(index); try { req.kill(); } catch (Throwable t) { @@ -3312,8 +3318,8 @@ private static void forget(int index) { } // Package private so a test can assert the bound rather than trust it. - static int outstandingRegistrationCountForTest() { - return outstandingRegistrations.size(); + static int outstandingRequestCountForTest() { + return outstanding.size(); } /// Forgets registrations old enough that nothing is coming back for them. @@ -3324,13 +3330,13 @@ static int outstandingRegistrationCountForTest() { /// the process. private static void pruneOutstanding() { long now = System.currentTimeMillis(); - for (int i = outstandingRegistrations.size() - 1; i >= 0; i--) { - InviteConnection req = outstandingRegistrations.elementAt(i); + for (int i = outstanding.size() - 1; i >= 0; i--) { + InviteConnection req = outstanding.elementAt(i); if (now - req.queuedAt >= OUTSTANDING_MAX_AGE_MS) { forget(i); } } - while (outstandingRegistrations.size() >= MAX_OUTSTANDING) { + while (outstanding.size() >= MAX_OUTSTANDING) { forget(0); } for (String json : new ArrayList(inFlight.keySet())) { @@ -3553,12 +3559,22 @@ static void markSentDirectlyForTest(String code) { unacknowledged.add(code); } + // Package private test seam: the acknowledgement normally arrives with a + // server response, and what has to be asserted is which entry it clears. + static void registrationAcknowledgedForTest(String json) { + registrationAcknowledged(json); + } + private static void registrationAcknowledged(String json) { - for (int i = unacknowledged.size() - 1; i >= 0; i--) { - String code = unacknowledged.get(i); - if (json != null && json.indexOf(code) >= 0) { - unacknowledged.remove(i); - } + // The acknowledged entry's own code, for the reason isRegistered() + // parses rather than searches: a substring test cleared an UNRELATED + // invite from the unacknowledged set whenever this registration's + // payload or title mentioned its code, and that one is worse than the + // false negative -- an invite the server has never seen then reports + // as registered. + String acknowledged = codeOf(json); + if (acknowledged != null) { + unacknowledged.remove(acknowledged); } List outbox = InviteStore.readOutbox(); if (outbox.remove(json)) { @@ -3592,14 +3608,42 @@ public static boolean isRegistered(Invite invite) { if (unacknowledged.contains(code)) { return false; } + // The queued entry's own code, parsed, not looked for anywhere in its + // text. A registration carries the campaign, the payload, the title and + // whatever parameters the application set, so another invite whose + // payload happens to contain this code -- a referral message quoting + // it, most obviously -- made a registration that WAS acknowledged + // report as still queued, and an application that waits for + // isRegistered() before sharing waits for ever. for (String pending : InviteStore.readOutbox()) { - if (pending != null && pending.indexOf(code) >= 0) { + if (code.equals(codeOf(pending))) { return false; } } return true; } + /// The top-level `code` of a queued registration, or null when the entry + /// cannot be parsed. + /// + /// Parsing rather than searching is the whole point: every other field in + /// the entry is application text, and an invite's code appearing inside one + /// of them says nothing about which registration this is. + private static String codeOf(String json) { + if (json == null || json.length() == 0) { + return null; + } + try { + Map parsed = JSONParser.parseJSON(json); + return parsed == null ? null : str(parsed.get("code")); + } catch (Throwable t) { + // An unparseable entry matches nothing, which leaves the invite + // reported as unregistered -- the conservative answer, and the one + // a retry can still correct. + return null; + } + } + private static boolean truthy(Object o) { if (o instanceof Boolean) { return ((Boolean) o).booleanValue(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index f4c50c52cbb..875fb4ff807 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -198,9 +198,9 @@ void queuedRegistrationsDoNotAccumulateWithoutBound() { "minting is offline and must still work"); } - assertTrue(Invites.outstandingRegistrationCountForTest() <= 32, + assertTrue(Invites.outstandingRequestCountForTest() <= 32, "queued registrations accumulated without bound: " - + Invites.outstandingRegistrationCountForTest()); + + Invites.outstandingRequestCountForTest()); } @FormTest @@ -273,6 +273,72 @@ void withdrawingConsentKillsARegistrationAlreadyOnItsWay() { "the durable outbox was discarded, so a later grant has nothing to send"); } + @FormTest + void anErasureKillsAqueuedClaimToo() { + // The kill sweep tracked registrations only, and a claim carries the + // same client id plus the code it is claiming -- so one queued behind + // other network work still transmitted the erased identity after + // reset() reported success. The epoch discards the response; nothing + // was stopping the request. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + implementation.clearQueuedRequests(); + + Invites.handleUrl("https://cloud.codenameone.com/i/acme/CLAIMKILL"); + java.util.List queued = + implementation.getQueuedRequests(); + assertTrue(queued.size() > 0, "the fixture queued no claim at all"); + + Invites.reset(); + + int checked = 0; + for (com.codename1.io.ConnectionRequest r : queued) { + if (r instanceof Invites.InviteConnection) { + assertTrue(((Invites.InviteConnection) r).killedForTest(), + "a claim queued before the erasure was still on its way out with " + + "the client id and the code it was claiming"); + checked++; + } + } + assertTrue(checked > 0, "no invite request was queued, so nothing was asserted"); + } + + @FormTest + void anInviteIsNotReportedRegisteredBecauseAnotherEntryMentionsItsCode() { + // The outbox scan matched the code anywhere in a queued entry's text, + // and an entry carries the campaign, the payload, the title and + // whatever parameters the app set. A referral message quoting another + // invite's code therefore made a registration that HAD been + // acknowledged report as still queued -- and an application that waits + // for isRegistered() before sharing waits for ever. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + + Invite first = Invites.create(InviteRequest.create().campaign("spring").build()); + assertNotNull(first, "minting is offline and must still work"); + // A second invite whose payload quotes the first one's code, which is + // exactly what a referral message does. + Invite second = Invites.create(InviteRequest.create() + .campaign("spring") + .payload("join me with " + first.getCode()) + .build()); + assertNotNull(second, "the fixture could not mint the second invite"); + + // The first one is acknowledged; the second stays queued. + for (String entry : InviteStore.readOutbox()) { + if (entry != null && entry.indexOf("\"" + first.getCode() + "\"") >= 0 + && entry.indexOf("join me with") < 0) { + Invites.registrationAcknowledgedForTest(entry); + } + } + + assertTrue(Invites.isRegistered(first), + "an acknowledged invite read as unregistered because another queued " + + "entry quoted its code"); + } + @FormTest void anErasureKillsARegistrationItCannotCatchOnTheDisk() { // create() hands the registration json to NetworkManager and returns, From 3c2be9e6c8d99e639902f0554185931099341064 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:53:06 +0300 Subject: [PATCH 67/70] Invites: why the App Clip overlay takes no app identifier A review round read the absence of an app identifier in the SKOverlay configuration as the defect -- the store id only guarding the call, never naming the app to install -- and asked for SKOverlayAppConfiguration with the store id instead. It is the wrong way round, and the SDK headers say so plainly: SKOverlayAppClipConfiguration -- "an overlay configuration that can be used to show an app clip's full app", with initWithPosition: and no identifier. SKOverlayAppConfiguration -- "...to show any app from the App Store", with initWithAppIdentifier:position:. An App Clip offering its own full app is the first case. The parent app is known from the bundle relationship, which is also what the data handoff is keyed to, so there is nothing to pass -- and the identifier form would offer an arbitrary store listing rather than this clip's parent. No behaviour change: the reasoning goes in the generated source, where the next person to read that call will be standing. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/util/InviteAppClipBuilder.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java index 39bb5615d32..dd6428da27a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java @@ -313,6 +313,23 @@ private static String delegateSource(String appGroup, String displayName, .append("// app. Without a store id -- which a build before first release\n") .append("// does not have -- the clip still records the code and simply\n") .append("// shows no sheet; the handoff works the moment the app exists.\n") + .append("//\n") + .append("// AppClipConfiguration is the RIGHT configuration here, and it\n") + .append("// takes no app identifier on purpose. A review round read that\n") + .append("// as the bug -- kStoreItemId only guarding, never identifying\n") + .append("// the app -- and asked for SKOverlayAppConfiguration with the\n") + .append("// store id instead. The SDK headers settle it:\n") + .append("// SKOverlayAppClipConfiguration: \"an overlay configuration\n") + .append("// that can be used to show an app clip's full app\",\n") + .append("// initWithPosition: only.\n") + .append("// SKOverlayAppConfiguration: \"...to show any app from the\n") + .append("// App Store\", initWithAppIdentifier:position:.\n") + .append("// The clip's parent app is known from the bundle relationship,\n") + .append("// so there is nothing to pass. Switching to the app-identifier\n") + .append("// form would offer an arbitrary store listing rather than THIS\n") + .append("// clip's parent, which is also what the data handoff is keyed\n") + .append("// to. The store id stays what it is: the build's own answer to\n") + .append("// whether there is a released app to offer yet.\n") .append("- (void)offerFullApp {\n") .append(" if (kStoreItemId.length == 0) { return; }\n") .append(" if (@available(iOS 14.0, *)) {\n") From b57d51e7e62bf9f30431a4f0954135894af2c737 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:01:19 +0300 Subject: [PATCH 68/70] Invites: the restored-install detection clears its flag in one save Preferences.set(String, Object) saves per key, and the two writes behind the restore detection were in the order that loses: the new install time landed, and a process that exited before the flag was cleared left storage saying this installation is the one that already read its referrer. The next launch compares equal, detection never fires again, and the Play referrer for that installation is gone for good -- the exact loss the detection was added to prevent, reachable whether or not any invite record survived the restore. Batched into one save, the same way persistDimensions() was: one write, no in-between to die in. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/referrer/AndroidInstallReferrer.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index d7e77d579f6..eadfbaefeff 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -32,6 +32,8 @@ import com.codename1.impl.android.AndroidNativeUtil; import com.codename1.io.Log; import com.codename1.io.Preferences; +import java.util.HashMap; +import java.util.Map; /// Reads the Play Store install referrer, which is the deterministic half of /// invite attribution on Android: the invite code makes the whole round trip @@ -128,8 +130,17 @@ private void forgetAflagRestoredFromAnotherInstallation(Context context) { return; } if (known != current) { - Preferences.set(PREF_INSTALL_TIME, current); - Preferences.set(PREF_ATTEMPTED, false); + // ONE save for both. Preferences.set(String, Object) saves per + // key, and the order that reads most naturally is the one that + // loses: the new install time lands, the process exits before + // the flag is cleared, and the next launch compares equal -- + // detection never fires again and this installation's Play + // referrer is gone for good. Batched, the pair is one write and + // there is no in-between to die in. + Map restored = new HashMap(); + restored.put(PREF_INSTALL_TIME, Long.valueOf(current)); + restored.put(PREF_ATTEMPTED, Boolean.FALSE); + Preferences.set(restored); } } catch (Throwable t) { // A package manager that cannot describe this app's own package is From 385750cdb3c32f7b200c2c807916822124a52813 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:19:33 +0300 Subject: [PATCH 69/70] Invites: an unanswered prompt stops the queue without settling anything Switching the consent MODE from OPT_OUT to OPT_IN with no choice on record withdraws the mode's implicit allow, so allowed() answers no from that moment -- but requests queued a moment earlier had already passed that gate and went on transmitting the client id and the invite metadata. The provider's no-choice branch did nothing for the new mode, and onConsentChanged(false), which is what otherwise kills them, must not be called here: it is the refusal path, and nothing has been refused. Reporting a refusal for an unanswered prompt would settle the lookup and clear the dimensions of a user who has answered nothing. So there is a narrow entry point that kills the queue and touches nothing else. The durable outbox stays, and a later grant sends it. The test asserts all three: the queued request is dead, the outbox survives, and the state is not DECLINED. The erasure marker's write result is no longer ignored. A failed write left the intent in memory alone, and since a plain reset() keeps the client id, a process exiting there meant the next launch saw no identity change and the surviving records came back. Nothing here can make a refusing store accept a write, and there is no second place to put it -- so it is logged as an ERROR naming that consequence, the flag stays set, every gated call retries the path, and resetVerified() still answers false. The comment says that is a limit rather than implying the case is handled. Writing the first test also caught a fixture of mine that proved nothing: freshInstall() grants consent, so the provider read the recorded choice and never reached the transition until the test cleared it. Co-Authored-By: Claude Opus 5 (1M context) --- .../invite/InviteAttributionProvider.java | 11 +++++ .../codename1/analytics/invite/Invites.java | 37 +++++++++++++++- .../invite/InviteResilienceTest.java | 42 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index d41ba320415..acdda2ffc01 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -151,7 +151,18 @@ public void onConsentChanged(AnalyticsConsent consent) { // stayed stopped and an attribution's dimensions stayed cleared. if (Analytics.getConsentMode() == ConsentMode.OPT_OUT) { Invites.onConsentChanged(true); + return; } + // OPT_IN with nothing on record, which is a transition in the other + // direction: the mode's implicit allow has just been withdrawn, so + // allowed() answers no from here on. Requests queued a moment ago have + // already passed that gate and would transmit the client id and the + // invite metadata after transmission stopped being permitted. + // + // Killing them is all that happens. onConsentChanged(false) is the + // refusal path -- it settles the lookup and clears the dimensions -- + // and nothing has been refused here: the prompt has not been answered. + Invites.suspendTransmission(); } @Override diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index f28f3600c86..76c72ef81fe 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1086,7 +1086,25 @@ public static void reset() { // back and were transmitted. Map owed = new LinkedHashMap(); owed.put("at", String.valueOf(System.currentTimeMillis())); - InviteStore.write(InviteStore.ERASURE, owed); + if (!InviteStore.write(InviteStore.ERASURE, owed)) { + // Said out loud, because this is the one state nothing + // here can recover from. The intent is still live in + // memory, every gated call retries this whole path while + // the flag is set, and resetVerified() answers false so + // the caller does not report the erasure as done -- but if + // the process exits before any write succeeds there is + // nothing on the disk to resume from, and a plain reset + // keeps the client id, so the next launch sees no identity + // change and the surviving records come back. + // + // There is no second place to write it that a store + // refusing this write would accept, so the honest handling + // is a loud log and a retry on the next call rather than + // an invented redundancy. + Log.p("invite: the erasure is owed and its marker could not be written, " + + "so it survives only in memory -- the records will come back " + + "if this process exits before a retry succeeds", Log.ERROR); + } } } } @@ -1340,6 +1358,23 @@ private static boolean settleErasure() { return !erasurePending || eraseInternal(); } + /// Stops what is already on its way out, without settling anything. + /// + /// The case is a consent MODE change: OPT_OUT to OPT_IN with no choice on + /// record flips `allowed()` from an implicit yes to an unanswered no. A + /// request queued a moment earlier has already passed that gate, so it + /// would transmit the client id and the invite metadata after transmission + /// stopped being permitted -- and `onConsentChanged(false)`, which is what + /// otherwise kills them, must not be called here: nothing has been + /// refused, and reporting a refusal would settle a lookup and clear + /// dimensions for a user who has answered no prompt at all. + /// + /// So this kills the queue and touches nothing else. The durable outbox + /// stays, and a later grant sends it. + static void suspendTransmission() { + killQueuedRequests(); + } + // Package private: called from the provider when consent changes. static void onConsentChanged(boolean allowed) { if (allowed) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 875fb4ff807..e28ecce85d2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -237,6 +237,48 @@ void anErasureWhoseMarkerSurvivesIsNotReportedDone() { "the fixture cleared the marker, so there is nothing to report about"); } + @FormTest + void switchingToOptInKillsWhatTheImplicitAllowHadQueued() { + // OPT_OUT to OPT_IN with nothing on record withdraws the mode's + // implicit allow: allowed() answers no from that moment. A request + // queued a moment earlier has already passed that gate, so it would + // transmit the client id and the invite metadata after transmission + // stopped being permitted. Nothing has been REFUSED, though -- the + // prompt has not been answered -- so the lookup must not settle and + // the dimensions must not clear. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + // No choice on record is the whole point: with one, the provider reads + // it and never reaches the mode transition. freshInstall() grants, so + // it has to be cleared first. + Analytics.setConsent(null); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + implementation.clearQueuedRequests(); + + assertNotNull(Invites.create(InviteRequest.create().campaign("spring").build()), + "minting is offline and must still work"); + java.util.List queued = + implementation.getQueuedRequests(); + assertTrue(queued.size() > 0, "the implicit allow queued nothing, so nothing is tested"); + + Analytics.setConsentMode(ConsentMode.OPT_IN); + + int checked = 0; + for (com.codename1.io.ConnectionRequest r : queued) { + if (r instanceof Invites.InviteConnection) { + assertTrue(((Invites.InviteConnection) r).killedForTest(), + "a request queued under the implicit allow was still on its way " + + "out after the mode withdrew it"); + checked++; + } + } + assertTrue(checked > 0, "no invite request was queued, so nothing was asserted"); + assertFalse(InviteStore.readOutbox().isEmpty(), + "the durable outbox was discarded for a prompt nobody has answered"); + assertTrue(Invites.getState() != Invites.STATE_DECLINED, + "an unanswered prompt was recorded as a refusal"); + } + @FormTest void withdrawingConsentKillsARegistrationAlreadyOnItsWay() { // The epoch decides whether an ANSWER is acted on, and a registration From 820a65567d1d15c6ed54cf61295e1fba683face1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:31:11 +0300 Subject: [PATCH 70/70] Invites: the App Links filter and the minted url agree about the slug The generated startup code stores invite.slug trimmed and the manifest filter used the raw hint, so a value written with a stray space made the app mint /i// while the pathPrefix kept the space. A link that does not match the filter opens the browser instead of the app -- and nothing reports it: the build succeeds, the filter is in the manifest, and every invite quietly misses it. Normalized in injectAppLinks(), which both outputs go through, rather than by a second trim at a call site that would have to remember. Verified against the unfixed code. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/InviteManifestFragments.java | 16 ++++++++++++++-- .../builders/InviteManifestFragmentsTest.java | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java index 9606aca748f..5f508d89b49 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java @@ -71,10 +71,22 @@ static String injectAppLinks(String existing, String host, String slug) { if (host == null || host.length() == 0) { return current; } - if (declaresInviteLinks(current, host, slug)) { + // Trimmed HERE, because the other reader of this hint trims too. + // + // The generated startup code stores invite.slug trimmed, so a hint + // written with a stray space around it made the app mint + // /i// while the pathPrefix in the manifest kept the + // space -- and a link that does not match the filter opens the browser + // instead of the app. Nothing reports it: the build succeeds, the + // filter is there, and every invite silently misses it. + // + // One normalization for both outputs, in the place both go through, + // rather than a second trim at a call site that has to remember. + String path = slug == null ? "" : slug.trim(); + if (declaresInviteLinks(current, host, path)) { return current; } - return current + filter(host, slug); + return current + filter(host, path); } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java index 4983e01132c..591adbc0525 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java @@ -222,4 +222,18 @@ void aFilterNamingNoSchemeAtAllCoversNothing() { + "android:pathPrefix=\"/i/\" />"; assertFalse(InviteManifestFragments.declaresInviteLinks(existing, HOST, "acme")); } + @Test + void aSlugWithStraySpaceStillMatchesTheLinksTheAppMints() { + // The generated startup code stores invite.slug trimmed, so a hint + // written with a space around it made the app mint /i// + // while the manifest's pathPrefix kept the space. A link that does not + // match the filter opens the browser instead of the app, and nothing + // reports it: the build succeeds and the filter is right there. + String out = InviteManifestFragments.injectAppLinks("", HOST, " acme "); + assertTrue(out.contains("android:pathPrefix=\"/i/acme/\""), + "the filter kept the whitespace the app trims: " + out); + assertFalse(out.contains("/i/ acme"), + "the untrimmed slug reached the manifest: " + out); + } + }