diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index e907cc077d1..306746bf9be 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; @@ -70,6 +71,17 @@ public final class Analytics { private static final String PREF_CONSENT_AD = "cn1$analyticsConsentAdStorage"; private static final String PREF_DIMENSIONS = "cn1$analyticsDimensions"; + // The client id the persisted dimensions were written under. + // + // Preferences.set discards the write-failure boolean, so an erasure that + // could not reach the disk removed the reserved dimensions from memory and + // left them in the file: the next launch loaded them back and attached the + // erased referral identity to the NEW client id, which is the one thing + // resetClientId() exists to prevent. Verifying the write closes that + // inside the process; this closes it across a restart, where no in-memory + // retry survives to run. + private static final String PREF_DIMENSIONS_OWNER = "cn1$analyticsDimensionsOwner"; + private static final Object LOCK = new Object(); private static final List PROVIDERS = new ArrayList(); // App-scoped segmentation dimensions ("plan", "role", ...) that the cloud @@ -147,8 +159,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); + } } } @@ -333,6 +371,12 @@ public static void setUserProperty(String key, String value) { /// with every first-party batch. Passing a null value removes the key. /// Null or empty keys are ignored. /// + /// The `cn1_` prefix is RESERVED for dimensions the framework writes on + /// your behalf, and those are cleared by [#resetClientId] because they + /// identify the user across installs. A key of your own under that prefix + /// is accepted -- it always was -- but it will be erased along with them, + /// so pick another one. + /// /// #### Parameters /// /// - `key`: the dimension key @@ -463,6 +507,13 @@ public static String clientId() { /// every provider with the new identity. Use this to honour a "right to be /// forgotten" / erasure request from the user. /// + /// Custom dimensions your application set are kept -- a `plan` or `role` + /// dimension describes the app, not the person, and losing it silently on + /// an erasure would surprise you. Dimensions under the reserved `cn1_` + /// prefix are cleared, because those are written for you by framework + /// features that identify the user across installs, and carrying them onto + /// a fresh id would re-link the two. + /// /// #### Returns /// /// the new client id @@ -471,6 +522,15 @@ public static String resetClientId() { synchronized (LOCK) { clientId = newClientId(); Preferences.set(PREF_CLIENT_ID, clientId); + // Cleared here rather than left to whichever feature wrote them. + // The feature's provider is the ordinary route and does more -- + // it drops its own durable records too -- but a provider can be + // absent: Analytics.clearProviders() is public and the deprecated + // AnalyticsService.init() calls it. In that window an erasure left + // the reserved dimensions attached to the new id, and the next + // provider the application registered transmitted them. An erasure + // cannot depend on who happens to be registered when it runs. + clearReservedDimensions(); snapshot = new ArrayList(PROVIDERS); } AnalyticsContext ctx = context(); @@ -484,6 +544,44 @@ public static String resetClientId() { return clientId; } + // Package private test seam: makes the store look the way it does after an + // erasure whose write never landed -- the reserved dimensions still in the + // file, stamped with the identity that has since been reset -- and drops + // the in-memory copy so the next read comes off the disk, which is what the + // next process would do. There is no other way to produce a failed + // Preferences write from a test. + static void simulateSurvivingDimensionsForTest(String raw, String owner) { + synchronized (LOCK) { + Preferences.set(PREF_DIMENSIONS, raw); + Preferences.set(PREF_DIMENSIONS_OWNER, owner); + DIMENSIONS.clear(); + dimensionsLoaded = false; + } + } + + /// The prefix reserved for dimensions the framework writes on your behalf. + /// Do not use it for your own dimensions: everything under it is cleared by + /// [#resetClientId]. + public static final String RESERVED_DIMENSION_PREFIX = "cn1_"; + + // Must be called while holding LOCK. + private static void clearReservedDimensions() { + loadDimensions(); + boolean changed = false; + Iterator> 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 @@ -497,6 +595,40 @@ private static void loadDimensions() { if (stored == null || stored.length() == 0) { return; } + // Whose dimensions these are. An erasure that could not reach the disk + // leaves the reserved entries in the file under the PREVIOUS identity; + // loading them would attach the referral the user asked to be rid of + // to their new client id, one launch later and with nothing in memory + // left to notice. + // + // An ABSENT stamp is ADOPTED, not treated as foreign, and the reason + // is specific enough to be worth writing down -- the strict reading + // was tried first and destroyed live data. + // + // Dropping a reserved dimension is only ever right when the FRAMEWORK + // wrote it, and the framework cannot have written one into an + // unstamped file. Every write of this record goes through + // persistDimensions(), which stamps in the same call, and Preferences + // keeps both keys in one record, so a file written by a version that + // owns reserved dimensions always carries a stamp. An absent one means + // the file predates the feature -- and back then `setDimension` + // accepted every key, documented no reserved prefix, and never wrote a + // `cn1_` dimension itself. So anything with that prefix in an + // unstamped file is the APPLICATION's, and dropping it silently + // deletes analytics segmentation from an app that did nothing wrong + // and never asked for an erasure. + // + // The erasure case the stamp defends against still works, because it + // cannot produce this state: the identity reset happens on a version + // that stamps, so the surviving file carries the PREVIOUS id and + // compares unequal below. + // + // clientId() rather than the field, because loading can happen before + // the id has been materialised and a null would make every file look + // foreign. It does not read dimensions, so there is no recursion. + String owner = Preferences.get(PREF_DIMENSIONS_OWNER, null); + boolean unstamped = owner == null; + boolean foreign = !unstamped && !clientId().equals(owner); String[] rows = split(stored, '\n'); for (String row : rows) { if (row.length() == 0) { @@ -508,18 +640,46 @@ private static void loadDimensions() { } String key = row.substring(0, tab); String value = row.substring(tab + 1); - if (key.length() > 0) { - DIMENSIONS.put(key, value); + if (key.length() == 0) { + continue; + } + if (foreign && key.startsWith(RESERVED_DIMENSION_PREFIX)) { + // The framework's own dimensions, belonging to an identity + // that has since been reset. Dropped rather than loaded: this + // is the erasure finishing late, and the alternative is + // handing the new client id the referral it was reset to + // forget. + // + // The APPLICATION's dimensions are kept. They are not what an + // erasure asked about, and losing a plan or role the app set + // would be a second bug in the name of fixing the first. + continue; } + DIMENSIONS.put(key, value); + } + if (foreign || unstamped) { + // Rewritten under the current identity so the drop -- or, for an + // unstamped file, the one-time adoption -- happens once. If this + // write fails the next launch simply repeats it, which is the + // correct outcome either way. + persistDimensions(); } } // Must be called while holding LOCK. + /// Writes the dimensions and the identity they belong to. + /// + /// There is deliberately NO read-back check here, and one was tried and + /// removed: `Preferences.set` updates a static table and `Preferences.get` + /// reads that same table, so reading a value back after writing it + /// compares memory with memory and reports success for a write that never + /// reached the disk. It looked like verification and verified nothing. + /// + /// The erasure is made safe by the stamp instead, which needs no write to + /// succeed -- see [#loadDimensions]. Both keys live in the SAME + /// preferences record, so they land together or not at all; there is no + /// state where the dimensions survive under a stamp that disowns them. private static void persistDimensions() { - if (DIMENSIONS.isEmpty()) { - Preferences.set(PREF_DIMENSIONS, ""); - return; - } StringBuilder b = new StringBuilder(); boolean first = true; for (Map.Entry e : DIMENSIONS.entrySet()) { @@ -529,7 +689,31 @@ private static void persistDimensions() { b.append(sanitize(e.getKey())).append('\t').append(sanitize(e.getValue())); first = false; } - Preferences.set(PREF_DIMENSIONS, b.toString()); + // ONE save for both keys. Preferences.set(String, Object) calls save() + // per key, so the two used to be two serializations of the whole map + // with a window between them -- and a comment here claimed they landed + // together because they share a record, which was simply wrong. + // + // The batched form makes that true instead of assumed. It is worth + // being precise about what it does and does not fix, because the + // obvious story is not the real one: save() writes the ENTIRE map, so + // a failed first save followed by a successful second still persisted + // both new values -- the "old dimensions under a new owner" state is + // not reachable that way. What the window really allowed was the + // reverse, a save that landed followed by one that did not, leaving + // new dimensions under the PREVIOUS stamp. loadDimensions() reads that + // as foreign and drops them, which is conservative and correct, and + // reconcileDimensions() puts them back from the durable record. One + // save removes the window rather than the consequence. + // + // clientId() rather than the field: the field is null until something + // materialises the id, and stamping a placeholder would make the file + // read as foreign on the next launch and drop the dimensions this call + // was in the middle of saving. + Map 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/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..fb498dfdf90 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java @@ -0,0 +1,101 @@ +/* + * 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); + + /// Told that the framework is done with the handoff, so a source holding + /// the only other copy must discard it. + /// + /// The iOS source hands over a value it reads out of the container it + /// shares with the App Clip, and that container is the ONLY durable copy + /// until the framework writes its own. Emptying it as it read meant a + /// failed write, or a process that exited in between, destroyed the exact + /// code -- and the next launch, finding no handoff, settled an invited + /// install as no_match for ever. So the read leaves the container alone + /// and this is what empties it. + /// + /// Two things end the framework's interest, and BOTH have to empty the + /// container, which is why this is one method rather than a + /// "persisted" one: + /// + /// - the code reached durable storage, so the copy is redundant. Never + /// called while the write is still failing: the code stays where it is + /// and the next launch reads it again, which is the outcome a retry can + /// still fix. + /// - the framework is FORGETTING -- [Invites#reset] or an erasure. A + /// handoff that was never consumed is still a code naming an inviter, + /// and the container is read on launch, so one left behind re-attributes + /// the device afterwards and undoes exactly what was erased. + /// + /// A source with nothing to discard -- anything that did not hand over its + /// only copy -- answers true here without doing anything. + /// + /// #### Returns + /// + /// true when no handoff is left on the device. An erasure is REFUSED on + /// false: the container is read on launch, so a copy that survives is an + /// exact code naming an inviter that the next launch re-attributes from, + /// and reporting an erasure that did not happen is worse than failing one + /// that can be retried. + boolean discardHandoff(); +} 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..1f97dc4981b --- /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 + void onReferrer(String rawReferrer, long referrerClickSeconds, + long installBeginSeconds); + + /// Called when no referrer can be obtained. This is the normal answer on + /// a device with no store client -- a sideload, an emulator without store + /// services, or a non-store distribution -- and is not an error. + /// + /// #### Parameters + /// + /// - `reason`: one of the `REASON_` constants on [Invites] + void onUnavailable(String reason); +} 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..e851762d654 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.analytics.invite; + +/// Reads the referrer the application store recorded when this application +/// was installed. This is the deterministic half of invite attribution: the +/// invite code makes the whole round trip through the store, so no matching +/// or guessing is involved. +/// +/// The Codename One build supplies the implementation on platforms that have +/// one and registers it through +/// [Invites#registerInstallReferrerSource] before the application starts. +/// Where none is registered -- the simulator, the desktop build, iOS, and any +/// Android device without store services -- [Invites] behaves exactly as it +/// does on a device that reports no referrer. +/// +/// An application does not implement this interface. +public interface InstallReferrerSource { + /// Whether this source can answer at all on the current device. + /// + /// #### Returns + /// + /// true when a store client is present + boolean isSupported(); + + /// Asks for the install referrer. The answer arrives on the callback, + /// possibly asynchronously and possibly on another thread; [Invites] + /// marshals it back onto the EDT. + /// + /// #### Parameters + /// + /// - `callback`: receives the answer, never null + void requestReferrer(InstallReferrerCallback callback); + + /// Told that the framework is done with the referrer, so a source holding + /// a one-shot flag must burn it. + /// + /// The Android source may only ask Play once: the API answers a given + /// install once, and the port records that it has asked so a later launch + /// does not throw the answer away by asking again. Burning that flag when + /// the value was merely HANDED OVER loses the exact code whenever the + /// process dies first -- the framework marshals onto the EDT, so the + /// persist is queued rather than done -- and the next launch then settles + /// an invited install as no-match, permanently, on the one platform whose + /// answer is exact. + /// + /// Two things end the framework's interest, and BOTH have to burn the + /// flag, which is why this is one method rather than a "persisted" one: + /// + /// - the referrer reached durable storage. Never called while that write + /// is still failing: the flag stays unburnt so the next launch can ask + /// again, which is the outcome a retry can fix. + /// - the framework is FORGETTING -- [Invites#reset] or an erasure. An + /// unconsumed referrer is still an exact code naming an inviter, and + /// Play answers the same install for as long as the flag is unburnt, so + /// one left behind re-attributes the device afterwards and undoes + /// exactly what was erased. + /// + /// #### Returns + /// + /// true when nothing is left that could answer again. An erasure is + /// REFUSED on false, for the same reason the App Clip handoff is: + /// reporting an erasure that did not happen is worse than failing one + /// that can be retried. A source with no flag answers true. + boolean discardReferrer(); +} 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..965839b18d0 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/Invite.java @@ -0,0 +1,115 @@ +/* + * 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; + + Invite(String code, String url, String campaign, String channel, String payload, + long createdTimestamp) { + this.code = code; + this.url = url; + this.campaign = campaign; + this.channel = channel; + this.payload = payload; + this.createdTimestamp = createdTimestamp; + } + + /// 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; + } + + // There is deliberately no isRegistered() here. This object is a value + // captured the moment the invite was minted, and registration completes + // asynchronously afterwards, so any flag stored on it could only ever + // report the value it was constructed with -- false, for ever, contradicting + // its own documentation. Ask [Invites#isRegistered(Invite)] instead, which + // reads the durable outbox and can actually answer. + + @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..e620a1aa5e1 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java @@ -0,0 +1,188 @@ +/* + * 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]. +/// +/// [#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; + 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_APP_CLIP]. + /// + /// #### Returns + /// + /// the match type, never null + public String getMatchType() { + return matchType; + } + + /// 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 + /// + /// 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 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, or 0 + 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..acdda2ffc01 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -0,0 +1,172 @@ +/* + * 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.Analytics; +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 +// 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) { + // 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)) { + // 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); + } + } + } + + @Override + public void onConsentChanged(AnalyticsConsent consent) { + // The argument cannot be trusted to distinguish "refused" from "not + // asked yet". Analytics.addProvider synthesizes AnalyticsConsent.denied() + // for the null state, and this provider is registered on every facade + // entry -- so a second launch before the user has answered the prompt + // would arrive here looking exactly like an explicit refusal, delete + // the deferred profile captured on the first launch, and move to + // DECLINED. A later grant could then never resume, and the invite that + // caused the install would be lost for a user who never refused + // anything. + // + // 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) { + Invites.onConsentChanged(recorded.isAnalytics()); + return; + } + // 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. + // 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); + 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 + 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..f21aa0e91ed --- /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 + 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] + 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..67fd64e62a4 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java @@ -0,0 +1,357 @@ +/* + * 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; + + /// The longest accepted preview image address. + public static final int MAX_IMAGE_URL_LENGTH = 512; + + /// The longest accepted custom parameter name. + public static final int MAX_PARAM_KEY_LENGTH = 64; + + /// The longest accepted custom parameter value. + public static final int MAX_PARAM_VALUE_LENGTH = 256; + + /// The most custom parameters an invite may carry. + public static final int MAX_PARAMETERS = 16; + + 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); + // Bounded HERE, with everything else, because these two were the + // way past every other bound. + // + // The request is serialized into the registration json and that + // json is persisted in the outbox, before anything has been sent + // and before any server has seen it. The outbox caps its ENTRY + // COUNT, which bounds nothing if one entry can be any size: an + // unbounded image address, or a parameter map an application + // filled in a loop, is written straight to storage. Refusing is + // the same answer the payload, title and description already give, + // and it arrives at the call that built the request rather than as + // a storage failure days later. + checkLength("imageUrl", imageUrl, MAX_IMAGE_URL_LENGTH); + if (parameters.size() > MAX_PARAMETERS) { + throw new IllegalArgumentException( + "an invite carries at most " + MAX_PARAMETERS + " parameters"); + } + for (java.util.Iterator> it = + parameters.entrySet().iterator(); it.hasNext();) { + java.util.Map.Entry e = it.next(); + checkLength("parameter name", e.getKey(), MAX_PARAM_KEY_LENGTH); + checkLength("parameter " + e.getKey(), e.getValue(), MAX_PARAM_VALUE_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..8db7336c733 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -0,0 +1,376 @@ +/* + * 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.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"; + + /// 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 + // inviter, campaign, payload or parameters, so a click on a link that was + // already shared can never be joined to any of it. + // + // 512 short JSON bodies is well under a megabyte, and reaching it means the + // device minted 512 invites without once reaching the network, which is far + // outside anything the design contemplates. An unbounded on-device queue is + // still not something to ship, so the ceiling stays -- but breaching it is + // logged rather than silent, because it means invites are being lost. + static final int MAX_OUTBOX = 512; + + 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); + // 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 (Object next : raw.entrySet()) { + 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 null; + } 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. + // 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) { + failWritesForTest(name, 1); + } + + // How many more writes of that record must fail. A single shot is not + // enough to hold a record undurable: readPending() retries the held copy + // the next time anything reads it, so a test that wants to observe "still + // not saved" has to outlast the retry as well as the first attempt. + private static int failNamedRemaining; + + static void failWritesForTest(String name, int count) { + failNextNamed = name; + failNamedRemaining = count; + } + + // 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)) { + failNamedRemaining--; + if (failNamedRemaining <= 0) { + failNextNamed = null; + } + return false; + } + 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; + } + } + + /// 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) { + 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; + } + try { + Storage s = Storage.getInstance(); + if (s == null) { + return false; + } + if (!s.exists(record)) { + return true; + } + s.deleteStorageFile(record); + if (!s.exists(record)) { + return true; + } + if (!s.writeObject(record, empty)) { + return false; + } + 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; + } + } + + 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) { + List raw = (List) o; + for (Object v : raw) { + if (v instanceof String) { + out.add((String) v); + } + } + } + } catch (Throwable t) { + Log.e(t); + } + return out; + } + + /// Returns false when the queue could not be persisted -- a full or + /// 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; + } + // 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; + } + if (copy.isEmpty()) { + // 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) { + Log.e(t); + } + return false; + } + + 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..41ad10f0b9e --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -0,0 +1,4260 @@ +/* + * 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.security.Hash; +import com.codename1.util.Base64; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +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. +/// +/// 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 +/// +/// [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; + + /// 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"; + + /// 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"; + + /// 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"; + + // 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"; + + /// 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"; + + // Package private so the unit tests can clear them between cases. + /// Display property carrying the invite host the build registered, stamped + /// by the builders from the `invite.domain` build hint. + static final String PROPERTY_DOMAIN = "invite.domain"; + + // The build stamps this beside the domain. It has to reach the client: + // the Android app-links filter and the iOS path claim are both scoped to + // /i//, so a link minted without the slug does not match the app's + // own filter and opens the browser instead. + 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. + + // 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"; + + // 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; + 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; + // Set when a clip handoff has been read and not yet made durable. The clip + // container is the only copy until then, so it must not be cleared early. + private static boolean handoffAwaitingAck; + // True when the last discard left the handoff where it was. The container + // is not one of our records, so this is the only way a reset can tell that + // something survived it. + private static boolean handoffSurvived; + + // 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; + 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; + + /// 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 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 + /// 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 + // 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 + // matches, so a request already on the wire cannot resurrect an attribution + // the user has just erased or refused. + private static int lookupEpoch; + + 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; + } + + /// 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. + /// + /// 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 + /// + /// #### Throws + /// + /// - `IllegalStateException`: when the device cannot supply secure + /// randomness. The code is the digest of a secret and that secret is + /// what proves who minted it, so a guessable one is a forgeable proof -- + /// an invite anybody it is shared with could register as their own. + /// Failing here is visible on the broken device; minting anyway is + /// invisible on every device the link reaches. + public static Invite create(InviteRequest request) { + if (request == null) { + throw new IllegalArgumentException("request is null"); + } + ensureProvider(); + String[] minted = newCode(); + String code = minted[0]; + // The proof goes to queueRegistration and NOWHERE else. It is not on + // Invite, which is public and handed to the application, and it is not + // in the url, which is the thing everybody can read. + String proof = minted[1]; + long now = System.currentTimeMillis(); + Invite invite = new Invite(code, buildUrl(code), request.getCampaign(), + request.getChannel(), request.getPayload(), now); + if (!queueRegistration(invite, request, proof)) { + // The outbox could not be persisted, and the invite has already + // been minted -- so the choice is between sending now and losing + // the registration for good. Send now: if it lands, the link is + // 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. + if (allowed()) { + 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 + // 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); + putIfSet(p, "campaign", request.getCampaign()); + putIfSet(p, "channel", request.getChannel()); + Analytics.autoEvent("invite_created", CATEGORY, p); + // 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; + } + + /// 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. + /// + /// 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 + public static boolean checkForInvite() { + ensureProvider(); + deliverPending(); + String appArg = null; + Display d = Display.getInstance(); + 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. + // 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) { + consumed = handleUrl(appArg); + if (consumed && d != null) { + d.setProperty("AppArg", null); + } + } + if (!consumed) { + resumeDeferred(); + } + 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; + } + // CONSUMED here, the moment the url is recognised as an invite. + // + // This is the documented route for an application that handles its own + // deep links, and it is reached from the external-url dispatch that + // Display.setProperty("AppArg", ...) fires synchronously. The Android + // onNewIntent splice queues a checkForInvite() behind that dispatch as + // the fallback for apps with no router -- and for an app that DOES + // route, the argument was still sitting there, so the queued check + // read the same url and handled it a second time: invite_opened twice + // on a resolved install, and on a pending one a duplicate claim whose + // epoch bump discarded the answer to the first. + // + // Only when it really is this url. An application may pass any string + // here, and clearing an unrelated launch argument is not ours to do. + 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; + } + // Consumed HERE, and not before the check above. + // + // Clearing it first threw the url away on the one path that reports + // failure: an erasure still owed and a store still refusing leaves + // this returning false, and the argument -- the only copy of a + // freshly tapped invite -- was already gone, so the retry that would + // have worked once storage recovered had nothing left to read. + // Everything below this line returns true, so from here the invite + // machinery really does own the url. + // + // Only when it really is this url. An application may pass any string + // here, and clearing an unrelated launch argument is not ours to do. + Display display = Display.getInstance(); + if (display != null && url != null && url.equals(display.getProperty("AppArg", null))) { + display.setProperty("AppArg", null); + } + // 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 + // 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; + } + // 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 = readPending(); + if (denied == null) { + denied = new LinkedHashMap(); + } + denied.put("code", code); + denied.put("codeSource", "universal_link"); + denied.put("codeMatch", MATCH_DIRECT); + denied.put("codeDeferred", "false"); + 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 + // registered one heard nothing at all. + if (markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED)) { + notifyUnavailable(REASON_CONSENT_DENIED); + } + return true; + } + 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; + } + // 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. + // 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"); + 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. + 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, + // and this direct claim supersedes it. Without the bump both answers + // pass the epoch guard, and a statistical match arriving second + // overwrites the exact one -- its dimensions and its durable record + // included. Advancing the epoch is how every other supersede in this + // class is expressed, and claim() reads the new value. + lookupEpoch++; + 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() { + 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; + } + + // Loads the durable record once. Guarded by a flag rather than by a null + // check on the field itself: "no attribution" is a real answer, so a null + // check would re-read storage on every call for the uninvited majority. + // There is no locking here and there should not be -- the facade runs on + // the EDT. + private static void loadAttribution() { + if (attributionLoaded) { + return; + } + attributionLoaded = true; + resolved = readAttribution(); + } + + // Drops everything cached in memory while leaving every durable record + // in place -- which is exactly what a process restart does. Package + // 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; + lookupIssuedAt = 0; + stateLoaded = false; + attributionLoaded = false; + resolved = null; + 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. + /// + /// #### Returns + /// + /// the current state + public static int getState() { + ensureProvider(); + loadState(); + return state; + } + + // Guarded by a flag rather than by a sentinel value on the field itself, + // for the same reason loadAttribution() is: STATE_NONE is a real answer, + // 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; + } + stateLoaded = true; + 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 + // answer to that is not a lock: this facade runs on the EDT and adding + // one would be the real mistake. + // 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 + // still stands, and answering STATE_RESOLVED from that old attribution + // made beginDeferred() return -- so a claim interrupted by process + // death was never retried and last touch silently kept losing to first. + // Without re-attribution the resolved record is the answer, because a + // stale pending record must never reopen a settled attribution. + if (reattribution && recorded == STATE_PENDING) { + state = STATE_PENDING; + return; + } + if (getAttribution() != null) { + state = STATE_RESOLVED; + return; + } + state = recorded; + } + + // ---- 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", Double.valueOf(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. + /// + /// **Set the `invite.domain` build hint to the same host.** This changes + /// where links are MINTED and nothing else. The Android intent filter and + /// the iOS associated-domain entitlement are written at BUILD time from + /// that hint, so a host set only here is a host the installed app does not + /// claim: every invite link opens the browser instead of the app, and + /// neither the OS nor the framework reports anything. A mismatch is logged + /// once, because it cannot be refused -- pointing at a staging service and + /// accepting the browser is a legitimate thing to do. + /// + /// A bare host is accepted and read as `https://`. Anything else that is + /// not HTTPS is REFUSED: Invite.getUrl() promises an absolute https url, + /// and the generated Android filter and iOS associated domain match + /// nothing else, so an http:// base mints links that always open the + /// browser -- and it would pass the host check below, which compares + /// hosts and not schemes. + /// + /// #### Parameters + /// + /// - `url`: the base address, with no trailing path + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when the address is not HTTPS + public static void setLinkBase(String url) { + String normalized = url; + if (normalized != null && normalized.trim().length() > 0) { + normalized = normalized.trim(); + if (normalized.indexOf("://") < 0) { + // A bare host, which is what the build hint carries and what + // an application copying it would naturally pass. + normalized = "https://" + normalized; + } + if (!normalized.regionMatches(true, 0, "https://", 0, 8)) { + throw new IllegalArgumentException( + "the invite link base must be https, not " + normalized); + } + // An ORIGIN, with no path of its own. + // + // A prefix check alone accepted https://links.example.com/base, + // which mints /base/i/ -- and the generated Android filter + // matches /i/, so every link opens the browser while the host + // check beside this stays silent, because the host is right. The + // path is the part the build cannot know about. + String origin = trimSlash(normalized); + String host = hostOf(origin); + if (host == null || origin.length() != "https://".length() + host.length()) { + throw new IllegalArgumentException( + "the invite link base must be a bare host, with no path: " + url); + } + } + linkBase = normalized; + warnIfNotTheRegisteredHost(normalized); + } + + /// Says so when links will be minted for a host the build did not register. + /// + /// The builders stamp the host they registered into the app, which is what + /// `getLinkBase()` prefers, so the two can simply be compared. Reported + /// rather than refused, and reported once: an application that sets this + /// on every start should not fill the log. + private static void warnIfNotTheRegisteredHost(String url) { + if (url == null || url.length() == 0 || linkBaseWarned) { + return; + } + Display d = Display.getInstance(); + String registered = d == null ? null : d.getProperty(PROPERTY_DOMAIN, null); + if (registered == null || registered.length() == 0) { + // Nothing was registered, so there is nothing to disagree with -- + // the default host is in the filter and the entitlement. + registered = DEFAULT_BASE_URL; + } + // hostOf() wants a scheme and the registered value may be a bare + // host, which is exactly what getLinkBase() compensates for when it + // reads the same property. + String a = hostOf(withScheme(url)); + String b = hostOf(withScheme(registered)); + if (a == null || b == null || a.equalsIgnoreCase(b)) { + return; + } + linkBaseWarned = true; + Log.p("Invites.setLinkBase(" + a + ") does not match the host this build " + + "registered (" + b + "). Links will be minted for " + a + ", but the " + + "Android intent filter and the iOS associated domains name " + b + ", so " + + "an installed app will NOT open its own invite links. Set the " + + "invite.domain build hint to " + a + " as well."); + } + + /// A bare host is what the build hint usually carries; hostOf() needs a + /// scheme to find one. + private static String withScheme(String url) { + return url == null || url.indexOf("://") >= 0 ? url : "https://" + url; + } + + private static boolean linkBaseWarned; + + /// 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(); + // The host the BUILD registered, stamped into the app by the builders + // from the invite.domain hint. Without this the client happily minted + // links for the default host while the generated Android intent filter + // and iOS associated domain named a custom one, so an installed app + // never opened its own links and nothing anywhere reported an error. + String host = d == null ? null : d.getProperty(PROPERTY_DOMAIN, null); + if (host != null && host.length() > 0) { + return trimSlash(host.indexOf("://") >= 0 ? host : "https://" + host); + } + 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) { + 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 + // 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; + // 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. + /// + /// #### 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() { + 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(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 + // this, the documented "I have just regained connectivity" call would + // 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 && !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 + // 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(); + } + } + + /// Forgets every trace of invite attribution on this device: the pending + /// 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 + /// identity to the same inviter. + public static void reset() { + 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; + // 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())); + 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); + } + } + } + } + + /// 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() { + // The App Clip container counts, and it is not one of the records + // below. + // + // resetVerified() reports false when the handoff could not be + // discarded, but the latch that makes a failed erasure retry was + // decided by reading the three InviteStore records -- all three of + // which had gone. So the one failure whose only survivor is OUTSIDE + // our storage returned false and latched nothing: no durable marker, + // nothing blocked, nothing retrying, and the surviving code read by + // the next launch. + if (handoffSurvived || referrerSurvived) { + return true; + } + 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. + /// + /// 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++; + // 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. + killQueuedRequests(); + // The App Clip's container too, and the case that needs it is the one + // where the handoff was never CONSUMED. + // + // Acknowledging on a durable write covers a code this process read. + // A code the clip left that nothing has read yet is still sitting in + // the shared container -- reset() or an erasure before the first + // checkForInvite() clears the store and leaves it there. The container + // is read on launch, so the next check finds it and attributes the + // device to exactly the inviter the erasure was asked to forget, and + // in the meantime the raw code sits on disk naming them. + // + // Unconditional, because "was it consumed?" is not knowable from here + // and the answer does not change what to do: forgetting means the copy + // goes either way. A source with nothing to discard answers true. + // + // GATED like every store deletion beside it. The result used to be + // dropped, so a container that refused to empty -- or a flush that did + // not reach the disk -- let the erasure report success with an exact + // code still on the device, which the next launch reads and + // re-attributes from. That is the one failure this method exists to + // refuse to hide. + boolean cleared = discardAnyHandoff(); + // The Play referrer too, and the case that needs it is the one where + // it was never CONSUMED. + // + // A reset before the first checkForInvite() -- an early logout, a + // privacy reset -- cleared the records and left the source's one-shot + // flag unburnt. Play answers the same install for as long as that flag + // is unset, so the next check read the original referrer and restored + // exactly the attribution the reset promised to forget. + cleared &= discardAnyReferrer(); + cleared &= InviteStore.delete(InviteStore.PENDING); + forgetPendingFallback(); + // 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 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); + clearDimensions(); + resolved = null; + // Loaded, and the answer is "none" -- not "unknown", or the next call + // would read the record we have just deleted back off the disk. + attributionLoaded = true; + state = STATE_NONE; + stateLoaded = true; + deliveredThisRun = false; + deferredStarted = false; + 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; + } + + // Package private test seam: the epoch an outstanding lookup was issued + // under, so a test can simulate a response that raced an erasure. + 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() { + 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() { + resolved = null; + attributionLoaded = false; + 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() { + // 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); + erasurePending = true; + return false; + } + // 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)) { + // 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); + erasurePending = true; + return false; + } + state = STATE_NONE_FOUND; + stateLoaded = true; + // The durable marker goes with the flag, or every later launch would + // 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; + } + + /// 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(); + } + + /// 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(); + // And the lookup is no longer outstanding, which has to be SAID. + // + // lookupIssuedAt is what lookupInFlight() answers from, and killing the + // requests left it stamped: for the rest of the retry interval the + // lookup was dead and the state said it was on its way. Granting + // consent inside that interval reaches onConsentChanged(true), which + // declines to restart a lookup it believes is already outstanding -- + // so the invite stayed unresolved until an explicit checkForInvite() + // after the delay, or the next launch, by which time the attribution + // window may have closed. + // + // The epoch goes up for the reason it goes up on an erasure or a + // withdrawal: the permission behind the outstanding lookup has just + // changed, and a response already on the wire must not be allowed to + // land against the state this leaves behind. + lookupEpoch++; + lookupIssuedAt = 0; + // Cleared too, or beginDeferred() would decline to start the lookup it + // is being restarted to run. + deferredStarted = false; + } + + // Package private: called from the provider when consent changes. + static void onConsentChanged(boolean allowed) { + if (allowed) { + // 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(); + // 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 + // reported to that listener as unavailable instead. + undelivered = null; + deferredStarted = false; + beginDeferred(); + } 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(); + 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. 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. + 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 + // 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 + // 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. + if (markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED)) { + 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. + // 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() { + resumeOwedErasure(); + reconcileDimensions(); + try { + List providers = Analytics.getProviders(); + for (Object provider : providers) { + if (provider 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(). + // A recorded choice that says no. Distinct from "no choice yet", which + // must never be treated as a refusal. + private static boolean explicitlyDenied() { + AnalyticsConsent c = Analytics.getConsent(); + return c != null && !c.isAnalytics(); + } + + /// Mints a code and the secret that proves who minted it. + /// + /// The code is the truncated SHA-256 of a random secret, and the SECRET is + /// what registration sends. The code is public by construction -- it is in + /// the share url -- so deriving it this way is what makes minting it a + /// thing only its creator can do. + /// + /// A code used to be the random bytes themselves, on the reasoning that it + /// "identifies an invite and authorizes nothing". That is true of every + /// path except the one that CREATES the server row: an invite shared while + /// its registration is still in the offline outbox is a public code with + /// no row behind it, and the server took the first registration of an + /// unknown code as its owner. A recipient of that link, running the same + /// shipped app and holding the build key that ships inside it, could + /// register it first under their own client id -- and then own the link, + /// while the real inviter's registration was refused as a different + /// inviter. Every install and every payout on a link they were merely sent + /// went to them. + /// + /// Truncated to the length the old code had, so urls do not change shape; + /// 22 base64 characters is 132 bits, which is preimage resistance nobody + /// is going to spend. + /// + /// #### Returns + /// + /// the code at index 0, and the proof to register it with at index 1 + private static String[] newCode() { + byte[] raw = new byte[16]; + try { + Util.secureRandomBytes(raw); + } catch (Throwable t) { + // NO FALLBACK. A mint without secure randomness does not happen. + // + // There used to be one, on the reasoning that weak randomness + // degrades uniqueness and that the fallback was "still a + // SecureRandom-seeded generator". That was simply untrue: + // FALLBACK_RANDOM was a plain java.util.Random, and this runtime + // implements it as a 48-bit linear congruential generator seeded + // from System.currentTimeMillis() (vm/JavaAPI java.util.Random). + // + // The secret is no longer only about uniqueness -- the code is its + // digest, and the secret is what proves who minted it. A recipient + // who has the public code and knows roughly when it was made can + // search that seed window, recover the proof, and register the + // invite as their own: exactly the theft the proof was added to + // prevent, handed back on the one platform whose CSPRNG is + // degraded. + // + // So this throws. An invite that cannot be minted is a visible + // failure on a broken device; an invite minted with a guessable + // proof is a silent one on every device it is shared with. + Log.e(t); + throw new IllegalStateException( + "invite codes need secure randomness, which this device did not " + + "provide; minting would produce a forgeable invite", t); + } + String proof = trimPadding(Base64.encodeUrlSafe(raw)); + String code = trimPadding(Base64.encodeUrlSafe(Hash.sha256(raw))); + if (code.length() > CODE_CHARS) { + code = code.substring(0, CODE_CHARS); + } + return new String[] {code, proof}; + } + + /// The code length, which is what the server truncates the digest to + /// before comparing. Both sides have to agree or no mint is ever accepted. + private static final int CODE_CHARS = 22; + + private static String trimPadding(String s) { + int pad = s.indexOf('='); + return pad > 0 ? s.substring(0, pad) : s; + } + + private static String buildUrl(String code) { + String slug = configuredSlug(); + 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; + } + // 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); + } + // The HOST is settled before either form is read. + // + // The query branch used to run first and return the moment it found + // the key, so any deep link the application handles for any other + // domain -- a partner's site, a campaign page, anything carrying + // cn1_invite in its query -- was accepted and claimed. That hands a + // fresh install, or a last-touch re-attribution, to whoever wrote a + // url this app happens to open. + // + // This function is the URL path only. The install referrer is a bare + // query string with no host at all, and it calls codeFromQuery() + // directly, so nothing about that path changes. + int q = url.indexOf('?'); + // HTTPS only, and the scheme is checked before the host. + // + // An application that forwards its broader deep links here could hand + // over myapp://cloud.codenameone.com/i/CODE or the http:// form, and a + // host-only test accepted both: the code was persisted and claimed + // although nothing the framework mints or the platforms associate is + // anything but https. The host being right is what made it look safe. + if (!url.regionMatches(true, 0, "https://", 0, 8)) { + return null; + } + 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; + } + if (q >= 0) { + String code = codeFromQuery(url.substring(q + 1)); + if (code != null) { + return code; + } + } + 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); + String pathSlug = slash > 0 ? rest.substring(0, slash) : null; + String mine = configuredSlug(); + if (pathSlug != null && mine != null && mine.length() > 0 && !mine.equals(pathSlug)) { + // ANOTHER app's invite, on the host we share with it. + // + // One domain serves every enrolled app, which is why the path + // carries a slug at all. A build whose App Links filter claims + // /i/ broadly -- which is what a hand-written filter usually does + // -- is handed /i/other-app/CODE by Android as readily as its own, + // and this took the last component regardless. The app then + // claimed a stranger's invite, and remembered their slug as its + // own, so its later mints advertised their links. + return null; + } + if (pathSlug != null && (mine == null || mine.length() == 0)) { + // Learned only when this build has no slug of its own to + // contradict: the bare form is what a first offline mint produces, + // and the server hands the slugged one back on registration. + Preferences.set(PREF_SLUG, pathSlug); + } + 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; + } + + // The build hint wins over the value the link service handed back: it is + // what the generated intent filter and the associated domain were scoped + // to, so minting anything else produces a link this build cannot open. + // The stored value is the fallback for builds that set no hint, where the + // server picks the slug and tells us on the first registration. + private static String configuredSlug() { + Display d = Display.getInstance(); + String slug = d == null ? null : d.getProperty(PROPERTY_SLUG, null); + if (slug != null && slug.trim().length() > 0) { + return slug.trim(); + } + return Preferences.get(PREF_SLUG, ""); + } + + 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; + stateLoaded = true; + Map pending = readPending(); + if (pending != null) { + pending.put("state", String.valueOf(s)); + writePending(pending); + } + } + + // Replaces the pending record with a marker that says only "asked, and the + // 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; + } + // 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; + lookupIssuedAt = 0; + return true; + } + + private static boolean hasSavedCode() { + Map pending = readPending(); + String code = InviteStore.get(pending, "code", null); + return code != null && code.length() > 0; + } + + private static boolean markTerminal() { + return markTerminal(null); + } + + // reason is recorded only when the answer could stop being true. A window + // of zero is the documented kill switch, and an application that later + // 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 boolean markTerminal(String reason) { + return markTerminal(STATE_NONE_FOUND, 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 + // 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. + // + // 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 = readPending(); + 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 + // 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 + // 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); + } + // 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, 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", "codeClicked"}) { + InviteStore.put(done, key, InviteStore.get(before, key, null)); + } + if (!writePending(done)) { + // 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; + } + state = terminalState; + stateLoaded = true; + return 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 = readPending(); + if (marker == null || InviteStore.getBoolean(marker, "delivered", false)) { + return null; + } + return InviteStore.get(marker, "reason", REASON_NO_MATCH); + } + + // 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 = 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; + } + if (InviteStore.getBoolean(marker, "delivered", false)) { + return true; + } + marker.put("delivered", "true"); + 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. + /// + /// - `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; + if (written) { + ackHandoff(record); + ackReferrer(record); + } + return written; + } + + /// Lets the App Clip drop its copy, once ours is durable. + /// + /// Here rather than beside the first write, because the first write is not + /// the only one that can make the record durable. A write that fails + /// leaves the record in `pendingFallback`, and `readPending()` retries it + /// the next time anything wants it -- so the code became durable with + /// nobody telling the clip, and its container kept the code for ever. + /// + /// That is not merely untidy. The container is read on launch, so a code + /// left in it outlives an erasure: the user erased their attribution, the + /// next launch found the handoff again and restored exactly what the + /// erasure promised to forget. + /// + /// Every path that persists goes through `writePending`, so acknowledging + /// here covers the retries without any of them having to remember to. + /// Tells the source to drop its copy, whatever the framework's reason. + /// + /// Separate from `ackHandoff` because the obligation flag does not apply: + /// forgetting has to reach a handoff this process never read, and there is + /// no record to check a codeSource against. + /// Tells the install-referrer source to burn its one-shot flag, whatever + /// the framework's reason. + /// + /// Separate from `ackReferrer` for the reason `discardAnyHandoff` is + /// separate from `ackHandoff`: forgetting has to reach a referrer this + /// process never read, and there is no record to check a codeSource + /// against. + private static boolean discardAnyReferrer() { + InstallReferrerSource source = referrerSource; + if (source == null) { + referrerAwaitingAck = false; + return true; + } + boolean gone; + try { + gone = source.discardReferrer(); + } catch (Throwable t) { + Log.e(t); + gone = false; + } + if (gone) { + referrerAwaitingAck = false; + } + referrerSurvived = !gone; + return gone; + } + + // True when the last discard left the referrer readable. Play answers the + // same install until the source's flag is burnt, so this is the only way a + // reset can tell that something survived it. + private static boolean referrerSurvived; + + private static boolean discardAnyHandoff() { + AppClipHandoffSource source = appClipSource; + if (source == null) { + handoffAwaitingAck = false; + return true; + } + boolean gone; + try { + gone = source.discardHandoff(); + } catch (Throwable t) { + Log.e(t); + gone = false; + } + // The obligation is cleared only when the copy really went. A handoff + // still sitting in the container is still owed to somebody, and a + // later durable write should ask again rather than assume. + if (gone) { + handoffAwaitingAck = false; + } + // Remembered for anythingSurvives(), which cannot see the container. + handoffSurvived = !gone; + return gone; + } + + /// Lets the install-referrer source burn its one-shot flag, once ours is + /// durable. + /// + /// The twin of `ackHandoff`, and here for the same reason: the first write + /// is not the only one that can make the record durable. A write that + /// fails leaves it in `pendingFallback`, and `readPending()` retries it -- + /// which `claim()` reaches on its way out -- so the referrer became + /// durable with the flag unburnt, and Play answered the same install again + /// on a later launch, restoring an attribution a reset had removed. + private static void ackReferrer(Map record) { + if (!referrerAwaitingAck + || !"install_referrer".equals(record.get("codeSource"))) { + return; + } + InstallReferrerSource source = referrerSource; + if (source == null) { + referrerAwaitingAck = false; + return; + } + boolean gone; + try { + gone = source.discardReferrer(); + } catch (Throwable t) { + Log.e(t); + gone = false; + } + if (gone) { + referrerAwaitingAck = false; + } + referrerSurvived = !gone; + } + + // True when a referrer has been handed over and not yet made durable. The + // source is holding a one-shot flag until it is. + private static boolean referrerAwaitingAck; + + private static void ackHandoff(Map record) { + if (!handoffAwaitingAck || !"app_clip".equals(record.get("codeSource"))) { + return; + } + AppClipHandoffSource source = appClipSource; + if (source == null) { + // Nothing left to tell, and nothing it could still be holding + // that this process can reach. + handoffAwaitingAck = false; + return; + } + boolean gone; + try { + gone = source.discardHandoff(); + } catch (Throwable t) { + Log.e(t); + gone = false; + } + // Cleared ONLY when the copy really went, which is the same rule + // discardAnyHandoff() follows and this one did not. + // + // The obligation was dropped before the answer was even read, so a + // removal the container refused -- or a flush that never reached the + // disk, which is exactly what the native side now reports -- was + // treated as done. The code then sat in the shared container for good: + // no later durable write asked again, and the container is read on + // launch, so it comes back if the framework's own record is ever lost + // or cleared. + // + // Left pending instead, and every later durable write retries it. + if (gone) { + handoffAwaitingAck = false; + } + handoffSurvived = !gone; + } + + /// 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 boolean pendingFallbackPresentForTest() { + return pendingFallback != null; + } + + static Map pendingRecordForTest() { + return readPending(); + } + + private static Map readPending() { + Map held = pendingFallback; + if (held != null) { + if (writePending(held)) { + // Invalidated here TOO, not only in loadState(). + // + // 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; + } + return InviteStore.read(InviteStore.PENDING); + } + + private static Map pendingRecord() { + Map pending = readPending(); + 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)); + writePending(pending); + 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; + } + // 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 + // application that later ships a non-zero one is asking again; a + // refusal is reversed by granting consent. Every other terminal answer + // 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 = readPending(); + String why = InviteStore.get(marker, "reason", null); + boolean reopen = (REASON_UNSUPPORTED.equals(why) && attributionWindow != 0) + || (REASON_CONSENT_DENIED.equals(why) && !explicitlyDenied()); + if (reopen) { + // 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)); + 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"); + writePending(marker); + state = STATE_PENDING; + stateLoaded = true; + s = STATE_PENDING; + } + } + if (s == STATE_RESOLVED || s == STATE_NONE_FOUND || s == STATE_DECLINED) { + return; + } + 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 + // documented once-per-install contract. + if (markTerminal(REASON_UNSUPPORTED)) { + notifyUnavailable(REASON_UNSUPPORTED); + } + return; + } + // Checked BEFORE the profile is created, not after. pendingRecord() + // persists on the spot, and onConsentChanged only deletes a record that + // already exists when it runs -- so creating one here for a user who + // had already refused left it on the device indefinitely, contradicting + // the documented promise that a refused profile is deleted. An UNSET + // choice still captures, which is the whole point: the match window + // closes long before a consent prompt is answered. + if (explicitlyDenied()) { + // 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. + if (markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED)) { + notifyUnavailable(REASON_CONSENT_DENIED); + } + return; + } + Map pending = pendingRecord(); + // 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; + } + if (markTerminal(REASON_EXPIRED)) { + notifyUnavailable(REASON_EXPIRED); + } + return; + } + if (InviteStore.getInt(pending, "attempts", 0) >= MAX_ATTEMPTS) { + if (abandonReplacement()) { + return; + } + if (markTerminal()) { + 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) { + // 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, + InviteStore.getLong(pending, "codeClicked", 0)); + return; + } + InstallReferrerSource source = referrerSource; + if (source != null && safeSupported(source)) { + requestReferrer(source); + return; + } + requestAppClipHandoff(); + } + + private static boolean safeSupported(InstallReferrerSource source) { + try { + return source.isSupported(); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + 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 + // 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; + // 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 + public void onReferrer(final String rawReferrer, final long clickSeconds, + final long beginSeconds) { + 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. + // That is an answer, not an outage. + 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.put("codeSource", "install_referrer"); + pending.put("codeMatch", MATCH_REFERRER); + 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"); + // The source is told only when the record really + // landed. It holds a one-shot flag -- Play answers + // an install once -- and burning it on handover + // lost the exact code whenever the process died + // inside the marshalling window. A failed write + // leaves the flag unburnt, so the next launch asks + // again, which is the outcome a retry can fix. + // Owed from here until the record is durable, + // which may be this write or a later retry of it. + // writePending() reports it either way -- keying + // it on this call alone missed the retry inside + // readPending(), which claim() reaches on its way + // out, so the record became durable with the + // one-shot flag left unburnt and Play answered the + // same install again after a reset. + referrerAwaitingAck = true; + writePending(pending); + claim(code, "install_referrer", + rawReferrer == null ? "" : rawReferrer, + MATCH_REFERRER, true, + clickSeconds > 0 ? clickSeconds * 1000L : 0); + } + }); + } + + @Override + 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 + // failed -- and the source deliberately does not + // burn its once-only flag for those, so a later + // launch can still read the exact referrer. The + // statistical fallback runs either way, but a + // no-match answer to it must not be allowed to + // settle the install as organic while a + // deterministic answer is still reachable. + // 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)); + } + }); + } + }); + } 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() { + fallBackToMatch(false); + } + + // retryable: the referrer could not be read this time but may be readable + // 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 = readPending(); + if (pending != null) { + if (retryable) { + pending.put("referrerRetry", "true"); + } else { + // Definitive: either the referrer was read and carries no + // invite, or the store says this device will never have one. + // Leaving an earlier outage's marker in place made the + // following no-match look retryable, so the lookup stayed + // pending and every launch asked again until the attempt cap. + pending.remove("referrerRetry"); + } + writePending(pending); + } + fallBackToMatchImpl(); + } + + private static void fallBackToMatchImpl() { + Map pending = readPending(); + if (pending == null) { + return; + } + requestAppClipHandoff(); + } + + 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); + } + } + + /// 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. + /// + /// 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 + // case: Android answered through the install referrer before + // reaching here, and the desktop and the simulator have neither. + // + // 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. + // Stamped, not cleared, for the reason handleResolution() gives. + // settleNoHandoff() does NOT always settle: when the pending + // record carries referrerRetry -- a transient Play failure, which + // is the ordinary way this is reached on Android -- it leaves the + // state pending on purpose. Clearing the stamp then made + // lookupInFlight() false immediately, so every later + // checkForInvite() bound the Play service again. These local + // attempts do not bump the persisted claim counter, so nothing + // bounded them but the attribution window. + lookupIssuedAt = System.currentTimeMillis(); + settleNoHandoff(REASON_NO_MATCH); + return; + } + // 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. + // + // 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() { + @Override + public void onHandoff(final String code, final long clickedSeconds) { + onEdt(new Runnable() { + @Override + 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", ""); + // 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)); + } + // Owed from here until the record is durable, which + // may be this write or a later retry of it. + // And the source may let go of its own copy only + // once ours is durable -- which writePending() reports + // by calling discardHandoff(), here or on whichever + // later retry succeeds. + // + // The container the clip wrote is the ONLY durable + // copy until then, so a source that emptied it as it + // read destroyed the exact code whenever the write + // failed or the process exited first -- and the next + // launch, finding no handoff, settled an invited + // install as no_match for ever. A write that failed + // leaves the container alone, so the next launch reads + // it again. + handoffAwaitingAck = true; + 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, + clickedSeconds > 0 ? clickedSeconds * 1000L : 0); + } + }); + } + + @Override + public void onUnavailable(final String reason) { + onEdt(new Runnable() { + @Override + public void run() { + if (issued != lookupEpoch) { + return; + } + 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; + } + // 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); + } + } + + 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; + } + Map pending = readPending(); + if (pending != null) { + bumpAttempts(pending); + } + Map body = identity(); + 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); + } + + private static void bumpAttempts(Map pending) { + pending.put("attempts", + String.valueOf(InviteStore.getInt(pending, "attempts", 0) + 1)); + writePending(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, String matchType, + boolean deferred) { + send(url, JSONParser.mapToJson(body), matchType, deferred, false); + } + + 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 ? outboxKey : null, lookupEpoch); + req.setUrl(url); + req.setPost(true); + req.setContentType("application/json"); + req.setRequestBody(json); + req.setFailSilently(true); + // 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); + } + } + + // 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. + // Package private so a test can exercise the response handling directly. + static final class InviteConnection extends ConnectionRequest { + private final String matchType; + private final boolean deferred; + private final boolean registration; + private final int epoch; + // 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 + // over it, so the status is the only thing that separates a real answer + // from a 503 -- and getResponseCode() alone is not enough to test + // against, because nothing can set it from outside the class. + private boolean failed; + + InviteConnection(String matchType, boolean deferred, boolean registration, + String outboxEntry, int epoch) { + this.matchType = matchType; + this.deferred = deferred; + this.registration = registration; + this.outboxEntry = outboxEntry; + this.epoch = epoch; + } + + @Override + 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() { + int code = getResponseCode(); + return failed || (code != 0 && (code < 200 || code > 299)); + } + + @Override + 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() { + outstanding.removeElement(this); + 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 + // this runs for a 503 too, and without the check a transient + // outage retired the durable registration as though the server had + // accepted it, while an error body parsed as "not resolved" turned + // a server fault into a permanent "you were not invited". + if (isFailed()) { + return; + } + if (registration) { + applySlug(payload); + if (outboxEntry != null) { + registrationAcknowledged(outboxEntry); + } + } else { + handleResolution(payload, matchType, deferred, epoch); + } + } + } + + // 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 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); + } + } + + // 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) { + handleResolution(payload, matchType, deferred, lookupEpoch); + } + + static void handleResolution(String payload, String matchType, boolean deferred, int epoch) { + if (epoch == lookupEpoch) { + // RE-STAMPED, not cleared, because an answer arriving is not the + // same as a question being settled. + // + // Clearing it said "nothing is on the wire", which is true, and + // was read by lookupInFlight() as "ask again whenever you like". + // For every response that settles something that is harmless -- + // the state is terminal and nothing asks again. For one that does + // NOT, and there are several that reach a plain return below + // without settling anything -- an empty body, JSON that will not + // parse, a resolved answer carrying no code -- it left the lookup + // pending with nothing to throttle it. Each later + // checkForInvite() then re-issued immediately and burned another + // of the five durable attempts, so a couple of lifecycle calls + // could settle an exact referrer or App Clip code as no_match in + // seconds. + // + // The retry interval is measured from this field, so recording the + // completed attempt is what makes it apply to a useless answer as + // well as to silence. The terminal paths do not care: they are + // gated on the state, not on this, and every reset and erasure + // clears it outright. + lookupIssuedAt = System.currentTimeMillis(); + } + // 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 + // them straight back, under the new identity, and undo the very + // operation the user asked for. + if (epoch != lookupEpoch || !allowed()) { + return; + } + // There is no kill-switch guard on the answer any more, because every + // answer is exact. + // + // 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. + // + // 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; + } + Map json = JSONParser.parseJSON(payload); + if (json == null) { + return; + } + 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. + // + // Stamped explicitly, although the entry to this method + // now stamps every response for the same reason. Kept + // because this is the path where it matters most and + // where the reasoning is easiest to lose: the state stays + // pending, so resumeDeferred() re-issues on the next + // checkForInvite(), and with no timestamp to throttle it + // an application calling that from two places would spend + // all five attempts in seconds and settle an + // offline-minted invite as no_match before its + // registration ever arrived. + lookupIssuedAt = System.currentTimeMillis(); + 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 + // once-only flag unset precisely so a later launch can read the + // exact referrer. Settling the install as organic here would + // throw that away for a statistical guess. Bounded by the + // attempt cap and the attribution window, both checked in + // beginDeferred(). + Map outstanding = readPending(); + if (outstanding != null + && "true".equals(InviteStore.get(outstanding, "referrerRetry", null))) { + // Deliberately silent. attributionUnavailable() is the + // terminal callback -- it means no invite will be + // attributed -- and this outcome is the opposite of + // terminal. Worse, it sets deliveredThisRun, so a referrer + // that succeeded moments later in the same process could no + // longer deliver inviteReceived(), and a relaunch could + // deliver it as a second outcome after the first said + // never. The listener hears nothing until there is an + // answer. + 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 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. + // 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 + // 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. + if (markTerminal()) { + 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; + } + // 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(); + Object rawParams = json.get("parameters"); + if (rawParams instanceof Map) { + Map raw = (Map) rawParams; + for (Object next : raw.entrySet()) { + 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); + } + } + } + } + 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())); + // The inviter's custom parameters are part of the attribution the app + // acts on, so they have to survive a restart -- an answer that arrives + // before the listener is registered is delivered on the NEXT launch, + // and would otherwise arrive stripped of them. + if (!a.getParameters().isEmpty()) { + InviteStore.put(record, "params", + JSONParser.mapToJson(new LinkedHashMap(a.getParameters()))); + } + // 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. + // 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 = readPending(); + } + 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 + // information, losing the resolution permanently at the next restart. + 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. + // 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 = readPending(); + if (retry != null) { + 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 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); + } + } + Log.p("invite: the attribution could not be persisted, so the lookup stays " + + "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 + // 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)) { + // 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; the correction is held and retried", Log.WARNING); + } + } + if (pendingCleared) { + forgetPendingFallback(); + } + resolved = a; + attributionLoaded = true; + state = STATE_RESOLVED; + stateLoaded = true; + 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) { + // Written unconditionally, nulls included -- setDimension(key, null) + // removes the key. Required under re-attribution: a later invite with + // no campaign used to leave the PREVIOUS campaign in place, so events + // carried the new code beside the old campaign and the last-touch + // cohort and its revenue were silently wrong. + Analytics.setDimension(DIMENSION_CODE, a.getCode()); + Analytics.setDimension(DIMENSION_CAMPAIGN, a.getCampaign()); + Analytics.setDimension(DIMENSION_CHANNEL, a.getChannel()); + 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. + /// 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; + } + dimensionsReconciled = true; + try { + 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 + // is nothing to guard here -- and SpotBugs, which is a + // zero-findings gate, says so. + Map set = Analytics.getDimensions(); + 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); + } + } + + // 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); + } + } + + 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), + parseParams(InviteStore.get(r, "params", null))); + } + + private static Map parseParams(String json) { + Map out = new LinkedHashMap(); + if (json == null || json.length() == 0) { + return out; + } + try { + Map parsed = JSONParser.parseJSON(json); + if (parsed != null) { + for (Object next : parsed.entrySet()) { + 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); + } + } + } + } + } catch (Throwable t) { + Log.e(t); + } + return out; + } + + // 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; + } + // Taken into a local and cleared unconditionally, rather than + // null-checked in place and cleared inside the branch. Same reason as + // 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; + } + Map r = InviteStore.read(InviteStore.ATTRIBUTION); + if (r == null || InviteStore.getBoolean(r, "delivered", false)) { + return; + } + InviteAttribution a = getAttribution(); + if (a == null) { + return; + } + r.put("delivered", "true"); + 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) { + Log.e(t); + } + } + + private static void notifyUnavailable(String reason) { + 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 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; + } + 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; + try { + target.attributionUnavailable(reason); + } catch (Throwable t) { + Log.e(t); + } + } + + // ---- registration outbox -------------------------------------------- + + // The JSON of the registration the last queueRegistration() built, so a + // failed enqueue can still be sent once rather than lost silently. + private static String pendingRegistration; + + // Codes whose registration was sent directly because the outbox could not + // be written. They are in flight and unacknowledged, and they are in no + // durable queue -- so isRegistered() cannot infer anything from the outbox + // for them and has to be told. In memory only, which is the honest limit: + // 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 Map inFlight = new LinkedHashMap(); + + // 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 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, and the reason is NOT what an earlier version of this comment + // claimed. It said these were touched from the network thread as well; + // they are not. postResponse() is handed to callSerially, so the release + // runs on the EDT, and the network thread's own hook -- handleException() + // -- never runs for these requests at all, because they are fail-silent + // and NetworkManager only logs. The collection is EDT-only like the rest + // of this class; the Vector is simply what it was written with and costs + // nothing to keep. + private static final java.util.Vector outstanding = + new java.util.Vector(); + + // 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 + // 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; + + /// 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 + /// 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 killQueuedRequests() { + while (!outstanding.isEmpty()) { + InviteConnection req = outstanding.elementAt(0); + outstanding.removeElementAt(0); + try { + req.kill(); + } catch (Throwable t) { + Log.e(t); + } + } + inFlight.clear(); + } + + /// 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 + /// 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 = outstanding.elementAt(index); + outstanding.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 outstandingRequestCountForTest() { + return outstanding.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 = outstanding.size() - 1; i >= 0; i--) { + InviteConnection req = outstanding.elementAt(i); + if (now - req.queuedAt >= OUTSTANDING_MAX_AGE_MS) { + forget(i); + } + } + while (outstanding.size() >= MAX_OUTSTANDING) { + forget(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, + /// 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. + /// + /// 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, + String proof) { + Map body = identity(); + body.put("code", invite.getCode()); + // Carried in the queued body, so a registration retried days later from + // the durable outbox still proves it was this device that minted the + // code. Held nowhere else: the outbox goes with an erasure, and the + // proof goes with it. + body.put("proof", proof); + // The mint time as the DEVICE saw it, which is the only record of when + // an offline invite was actually created. + // + // The server stamped createdAt at registration, and for an invite + // minted offline that can be hours or days late. Everything downstream + // that asks "was this person already here before the invite existed" + // then compares against the wrong instant: the recipient's own + // post-install events fall BEFORE it, the genuine acquisition is + // marked a prior user, and it drops out of the ranking referral + // bounties are paid from. + // + // A device clock is not trusted, only offered -- the server clamps it. + body.put("createdAt", Long.valueOf(invite.getCreatedTimestamp())); + 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())); + } + 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); + } + + /// 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. + /// 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; + } + // 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()) { + return; + } + // Each entry is removed by its OWN successful response, never here. + // Clearing the queue at send time looked harmless and was not: the + // registration carries the campaign, channel, payload and preview + // metadata, and none of it can be reconstructed from a click. The case + // that loses it is exactly the case the outbox exists for -- an invite + // minted with no network, which is the reason minting is offline in + // the first place. + // + // 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 && 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.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. + postRegistration(withCurrentConsent(json), 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; + } + } + + private static void postRegistration(String json) { + 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. + // Package private test seam: puts a code in the in-flight set without + // having to make the durable store fail on demand. + 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) { + // 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); + } + // Read-modify-write, and deliberately unguarded: this runs on the EDT, + // and so does everything else that touches the outbox. + // + // A review round read it as a race -- a response landing on the + // network thread while the EDT mints, so one overwrites the other's + // queue -- and asked for a lock. There is no such interleaving: + // ConnectionRequest hands postResponse() to + // Display.getInstance().callSerially(), so it runs on the EDT like + // create(), flush() and reset(). The network thread's own hook, + // handleException(), touches the in-flight marks and never the outbox + // -- and for these requests it does not run at all, because they are + // fail-silent and NetworkManager only logs. + // + // A lock here would be the wrong answer to a question nobody asked: + // this framework is single-threaded on the EDT by design, and the one + // real boundary -- the native callbacks -- is marshalled with + // callSerially before it reaches any of this. + List outbox = InviteStore.readOutbox(); + if (outbox.remove(json)) { + InviteStore.writeOutbox(outbox); + } + } + + /// Whether the link service has acknowledged this invite. + /// + /// An unacknowledged invite is still shareable and still attributes -- + /// registration is retried until it lands -- so this is a diagnostic + /// rather than a gate. + /// + /// #### Parameters + /// + /// - `invite`: the invite to ask about, may be null + /// + /// #### Returns + /// + /// true once the server has acknowledged it + public static boolean isRegistered(Invite invite) { + if (invite == null) { + return false; + } + String code = invite.getCode(); + // Absence from the outbox is not acknowledgement on its own. When the + // store could not be written the registration was sent directly and + // never queued, so the outbox says nothing about it -- reading that + // silence as success reported an in-flight, and possibly failed, + // registration as acknowledged. + 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 (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(); + } + 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..af9d25cd49c --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/package-info.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. + */ +/// 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 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/CodenameOne/src/com/codename1/components/InviteButton.java b/CodenameOne/src/com/codename1/components/InviteButton.java new file mode 100644 index 00000000000..e122ab7e580 --- /dev/null +++ b/CodenameOne/src/com/codename1/components/InviteButton.java @@ -0,0 +1,391 @@ +/* + * 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.io.Log; +import com.codename1.ui.Display; +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; + // Two fields, because they answer two different questions and clearing + // one on a result silently broke the other. `invite` is what getInvite() + // reports -- the invite minted for the most recent press, which an + // application reads from inside its own ShareResultListener to tell which + // invite the ShareResult belongs to, so it has to survive the result. + // `outstanding` is the one still awaiting a result, and exists only to + // stop a second press minting a second code; it is cleared as soon as the + // outcome is taken, so that outcome can be reported exactly once. + private Invite invite; + private Invite outstanding; + private ShareResultListener appListener; + // The chained listener super was given. Package private so a test can + // deliver a ShareResult without the share sheet -- getShareResultListener() + // is overridden to answer with the application's listener, so the chain is + // otherwise unreachable from outside a real press. + ShareResultListener chain; + // True from a press until the next EDT cycle, which is when ShareButton's + // deferred runnable has already presented. Its ONLY job is to collapse + // presses that arrive before that; it is never a "share in progress" + // flag, because nothing guarantees a share ever reports. + private boolean presenting; + + /// 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() { + chain = new ShareResultListener() { + @Override + public void onResult(com.codename1.share.ShareResult result) { + // Taken and CLEARED, so the next press mints again and this + // outcome can only ever be reported once. Only the outstanding + // mark is cleared -- getInvite() still answers, because the + // application's listener runs below and correlating the result + // with its invite is the whole reason that accessor exists. + Invite current = outstanding; + outstanding = null; + if (current != null) { + Invites.reportShareResult(current, result); + } + if (appListener != null) { + appListener.onResult(result); + } + } + }; + super.setShareResultListener(chain); + } + + /// 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) { + // A press is dropped only while ANOTHER PRESS IS STILL ON ITS WAY to + // the share sheet -- not for as long as a share is outstanding. + // + // ShareButton defers to the next EDT cycle and then shares + // unconditionally, so two presses within one cycle enqueue two + // presentations: two native sheets attempted, the application's + // listener called twice, and -- because the first result takes + // `outstanding` -- the second share reported to nobody. + // + // Keying that on `outstanding` instead would have been a far worse + // bug than the one it fixed. Display.share() documents that the + // listener always runs, but on Android the API 22+ chooser callback + // deliberately does not: "Android does not expose a dismissal signal + // for the chooser, so the listener simply does not fire on user-cancel" + // (AndroidImplementation.buildShareChooserWithCallback). A user who + // opens the sheet and backs out would leave `outstanding` set with + // nothing to clear it, and the button would never share again until + // the form was rebuilt. + // + // This flag cannot do that: it is cleared on the next EDT cycle + // whatever happens, by a runnable queued behind the one ShareButton + // itself queues. Nothing about the sheet, the platform or the user's + // answer can hold it. + if (presenting) { + return; + } + presenting = true; + if (mintForShare() == null) { + // Nothing was minted -- the device could not supply secure + // randomness -- so there is no link to share. Presenting anyway + // would open the sheet on whatever text was set last. + presenting = false; + return; + } + presentShare(evt); + Display d = Display.getInstance(); + if (d == null) { + // No EDT to clear it on, so it was never set. + presenting = false; + return; + } + d.callSerially(new Runnable() { + @Override + public void run() { + presenting = false; + } + }); + } + + /// Hands the press to [ShareButton], which presents the sheet. + /// + /// Package private so a test can count presentations. Whether a second + /// press presents a second time is not observable otherwise: ShareButton + /// defers to the next EDT cycle, and the sheet it opens there is the one + /// part of a press that cannot run headless. + /// + /// ShareButton defers by one EDT cycle, so the text set in + /// `mintForShare()` is in time. + /// + /// #### Parameters + /// + /// - `evt`: the press + void presentShare(ActionEvent evt) { + super.actionPerformed(evt); + } + + /// Mints the invite this press will share, or keeps the one still + /// outstanding, and sets the text. + /// + /// Package private so a test can drive it without the share sheet: the + /// sheet is the one part of a press that cannot run headless, and the + /// question this answers -- how many invites two presses mint -- is + /// decided before it opens. + Invite mintForShare() { + InviteRequest.Builder b = InviteRequest.create(); + if (campaign != null) { + b.campaign(campaign); + } + if (channel != null) { + b.channel(channel); + } + if (payload != null) { + b.payload(payload); + } + // One outstanding invite at a time, and a second press before the + // first sheet has answered reuses it rather than minting another. + // + // The share sheet is modal, so a double tap does not open two of them + // -- it mints two codes and shares the later one, leaving the first + // registered, counted as invite_created, and never shared by anybody. + // Worse, the result is reported against whichever invite the field + // held when it arrived, so with two sheets the answer for one could be + // recorded against the other. + // + // Reusing removes both: the outcome belongs to exactly one invite by + // construction. Sharing one code more than once is the ordinary shape + // of a referral anyway -- a code is not per recipient, it is the + // inviter's -- so nothing is lost by not minting a second. + if (outstanding == null) { + try { + outstanding = Invites.create(b.build()); + } catch (IllegalStateException e) { + // The device could not supply secure randomness, so there is + // no invite to share. Nothing is presented rather than + // sharing a link somebody else could claim -- see + // Invites.create(). + Log.e(e); + return null; + } + invite = outstanding; + } + // The outstanding one, not the accessor's: this is the invite whose + // url goes into the sheet, and the two only ever differ if a future + // change lets them. + String text = message == null || message.length() == 0 + ? outstanding.getUrl() : message + " " + outstanding.getUrl(); + setTextToShare(text); + return outstanding; + } + + /// {@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/Ports/Android/build.xml b/Ports/Android/build.xml index 962484d56f7..3630dc0a1fc 100644 --- a/Ports/Android/build.xml +++ b/Ports/Android/build.xml @@ -117,10 +117,20 @@ backend is there for the same reason 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 excludes= - entry in nbproject/project.properties and in maven/android/pom.xml. --> + 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 diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 7c81f0785eb..e0dbb9f819b 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1654,6 +1654,97 @@ public static void clearAppArg() { } } + /// Delivers a link that arrived at an already-running activity, so the + /// router sees it on Android as it already does on iOS. + /// + /// The two ports were asymmetric here, and silently so. iOS routes every + /// deep link through `Display.setProperty("AppArg", url)`, which fires + /// [com.codename1.router.Navigation#dispatchExternalUrl]. Android's + /// `onNewIntent` only stored the intent, and [#getAppArg] then derived + /// the value lazily through the implementation's own setter -- so + /// `setProperty` never ran and the router never fired. Anything built on + /// `@Route` therefore worked on iOS and did nothing on Android, which + /// reads as a feature that "just doesn't convert" on the platform rather + /// than as a bug. + /// + /// Deliberately narrow. Only `ACTION_VIEW` with an http or https scheme + /// goes through here; `EXTRA_TEXT` shares, `content://` attachments and + /// `EXTRA_STREAM` payloads keep their existing lazy path. Dispatching for + /// every intent would double-fire against the `setAppArg` inside + /// [#getAppArg] and would change behaviour for every share-target + /// application in the field. + /// + /// #### Parameters + /// + /// - `intent`: the intent delivered to the running activity + static void dispatchNewIntentUrl(Intent intent) { + if (intent == null || instance == null || !Display.isInitialized()) { + return; + } + try { + if (!Intent.ACTION_VIEW.equals(intent.getAction())) { + return; + } + android.net.Uri data = intent.getData(); + if (data == null) { + return; + } + String scheme = data.getScheme(); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + return; + } + // Cleared first so the value below is what getAppArg() reports, + // rather than whatever the previous intent left cached. + instance.setAppArg(null); + clearIntentProperties(); + // The intent is stored UNMODIFIED, and the url is marked as delivered by + // remembering the intent's identity instead of by erasing its data. + // + // 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. + // + // 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()) { @@ -3723,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/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java index 890fde9eae2..9bda03998df 100644 --- a/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java @@ -314,6 +314,7 @@ protected void onSaveInstanceState(Bundle outState) { protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); + AndroidImplementation.dispatchNewIntentUrl(intent); } @Override 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..d7d09c9e920 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -0,0 +1,417 @@ +/* + * 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; +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 +/// 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"; + + /// 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. + // 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() { + 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) { + // 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 + // 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 + 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(attemptSeq, callback, 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(attemptSeq, callback, Invites.REASON_UNSUPPORTED); + } + } + + private void connect(final InstallReferrerClient client, + final InstallReferrerCallback callback) { + // 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(issued, 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. + // Only the exchange that is STILL CURRENT may + // retry. `retried` is shared and the newer + // exchange resets it, so a binding that outlived + // lookupRetryDelay could come back with this + // transient code, find the flag clear, advance the + // sequence and start its own retry -- invalidating + // the newer exchange, whose answer might have been + // the exact referrer. Every other branch here + // already passes `issued` to a method that checks + // it; this one decided on its own. + if (issued != attemptSeq) { + close(client); + return; + } + if (!retried) { + retried = 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); + attempt(callback); + return; + } + // Transient, so it is NOT recorded as attempted. + // Burning the once-only flag here would make + // isSupported() false for ever, and a later + // Invites.flush() after the store recovered would + // skip the deterministic path and fall back to a + // statistical guess for a referrer we could have + // 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 + // emulator without store services, another vendor's + // store. Terminal: this device will never have a + // referrer, so the flag is recorded and the bind is + // not attempted again. + 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(issued, callback, Invites.REASON_NO_MATCH); + } finally { + close(client); + } + } + + @Override + public void onInstallReferrerServiceDisconnected() { + // 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. + unavailable(issued, callback, Invites.REASON_NO_MATCH); + } + }); + } + + private void deliver(int issued, InstallReferrerClient client, + InstallReferrerCallback callback) { + String referrer = ""; + long clickSeconds = 0; + long beginSeconds = 0; + boolean threw = false; + try { + ReferrerDetails details = client.getInstallReferrer(); + if (details != null) { + referrer = details.getInstallReferrer(); + clickSeconds = details.getReferrerClickTimestampSeconds(); + 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. + unavailable(issued, callback, Invites.REASON_NO_MATCH); + return; + } + 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. + // + // 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 only. The flag is burnt by discardReferrer(), which + // the framework calls once the code is in durable storage. + // + // Burning it here lost the exact code whenever the process died first: + // the framework marshals onto the EDT, so a callback arriving on a + // binder thread leaves the persist QUEUED, and the next launch then + // saw isSupported() false and settled an invited install as no-match -- + // permanently, on the one platform whose answer is exact. The window + // was small and it was unbounded in consequence. + // + // This exchange is still marked as having ANSWERED, so a superseded or + // duplicate callback cannot answer again; what waits for durability is + // only the one-shot flag that decides whether Play is ever asked again. + referrer(issued, callback, referrer, clickSeconds, beginSeconds); + } + + /// Burns the one-shot flag, once the framework has the code durably. + /// + /// Play answers a given install once, so asking again would throw the + /// answer away -- which is what this flag prevents. It is set HERE rather + /// than at handover so that a process killed before the framework's write + /// lands leaves it unset, and the next launch asks Play again instead of + /// losing the referrer for good. + @Override + public boolean discardReferrer() { + Preferences.set(PREF_ATTEMPTED, true); + // READ BACK, because Preferences.set() answers nothing. A store that + // refused leaves the marker absent for good, and Play then returns the + // same install referrer on a later launch -- restoring an attribution + // an erasure had removed. The caller gates that erasure on this, so + // "I called set()" is not the answer it needs. + return Preferences.get(PREF_ATTEMPTED, false); + } + + /// 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) { + if (unavailable(issued, callback, reason)) { + Preferences.set(PREF_ATTEMPTED, true); + } + } + + /// 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 boolean unavailable(int issued, InstallReferrerCallback callback, String reason) { + if (answered || issued != attemptSeq) { + return false; + } + answered = true; + callback.onUnavailable(reason); + return true; + } + + private boolean referrer(int issued, InstallReferrerCallback callback, String value, + long clickSeconds, long beginSeconds) { + if (answered || issued != attemptSeq) { + return false; + } + answered = true; + callback.onReferrer(value, clickSeconds, beginSeconds); + return true; + } + + private void close(InstallReferrerClient client) { + try { + client.endConnection(); + } catch (Throwable t) { + Log.e(t); + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 48bb776c070..4a6e9c47ce2 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -7532,6 +7532,90 @@ public void actionPerformed(ActionEvent e) { }); simulateMenu.add(appArg); + // Invite attribution. "Send App Argument" above already covers the + // installed-app path -- paste an invite link into it. What it cannot + // reach is the DEFERRED path, which is the half most likely to ship + // broken: the referrer parser is otherwise only exercised by a real + // Play install, on a real device, once. + final JMenu inviteMenu = new JMenu("Invite"); + + JMenuItem inviteReferrer = new JMenuItem("Simulate Deferred Install Referrer..."); + inviteReferrer.setToolTipText("Answer the next invite attribution lookup with a " + + "synthetic Play install referrer"); + inviteReferrer.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + JPanel pnl = new JPanel(); + JTextField tf = new JTextField(20); + pnl.add(new JLabel("Invite code")); + pnl.add(tf); + int val = JOptionPane.showConfirmDialog(canvas, pnl, + "Simulate an install referrer", JOptionPane.OK_CANCEL_OPTION, + JOptionPane.QUESTION_MESSAGE); + if (val != JOptionPane.OK_OPTION) { + return; + } + final String code = tf.getText() == null ? "" : tf.getText().trim(); + if (code.length() == 0) { + return; + } + // The exact shape the link service puts on the Play url, so + // the parser under test is the real one. + final String referrer = "utm_source=cn1_invite&utm_medium=referral" + + "&cn1_invite=" + code; + com.codename1.analytics.invite.Invites.registerInstallReferrerSource( + new com.codename1.analytics.invite.InstallReferrerSource() { + @Override + public boolean isSupported() { + return true; + } + + @Override + public void requestReferrer( + com.codename1.analytics.invite.InstallReferrerCallback callback) { + long now = System.currentTimeMillis() / 1000L; + callback.onReferrer(referrer, now - 60L, now); + } + + @Override + public boolean discardReferrer() { + // The simulator has no one-shot flag to burn: the menu + // item is the trigger, and it can be used again. + return true; + } + }); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + com.codename1.analytics.invite.Invites.checkForInvite(); + } + }); + } + }); + inviteMenu.add(inviteReferrer); + + JMenuItem inviteClear = new JMenuItem("Clear Invite Attribution State"); + inviteClear.setToolTipText("Forget the attribution and the pending device profile, so " + + "the first-launch path can be run again"); + inviteClear.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + // Without this the once-per-install semantics make the + // deferred path testable exactly once per machine, which is + // how once-only bugs get shipped. + com.codename1.analytics.invite.Invites.registerInstallReferrerSource(null); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + com.codename1.analytics.invite.Invites.reset(); + } + }); + } + }); + inviteMenu.add(inviteClear); + + simulateMenu.add(inviteMenu); + JMenuItem debugWebViews = new JMenuItem("Debug Web Views"); debugWebViews.setEnabled(false); @@ -8273,6 +8357,7 @@ public void actionPerformed(ActionEvent e) { simulateMenu.removeAll(); simulateMenu.add(pause); simulateMenu.add(appArg); + simulateMenu.add(inviteMenu); simulateMenu.addSeparator(); simulateMenu.add(locationSim); simulateMenu.add(bluetoothSim); diff --git a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m new file mode 100644 index 00000000000..0b08bbc27f6 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m @@ -0,0 +1,203 @@ +/* + * 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_readAppClipInviteHandoff___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]; + // Flushed, for the same reason the clip flushes its write: see + // clearAppClipInviteHandoff below. + [suite synchronize]; + } + 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; + + // NOT cleared here. This container is the only durable copy of the code + // until the framework writes its own record, so emptying it as it was read + // destroyed the exact code whenever that write failed or the process + // exited in between -- and the next launch, finding no handoff, settled an + // invited install as no_match for ever. Read once is still the contract; + // clearAppClipInviteHandoff below is what enforces it, at the point where + // losing the value costs nothing. + // + // The malformed case above is different and still clears: a record that + // cannot be parsed will never become valid, and leaving it means reading + // the same garbage on every launch. + + 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); +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_lang_String_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + NSUserDefaults *suite = cn1InviteSuite(toNSString(CN1_THREAD_STATE_PASS_ARG groupObj)); + if (suite == nil) { + // No container, so there is nothing left holding a code. + return JAVA_TRUE; + } + [suite removeObjectForKey:kCN1InviteHandoffKey]; + // Flushed before returning, exactly as the generated clip flushes the + // write this undoes. + // + // NSUserDefaults writes back on its own schedule, so an app terminated + // between this call and that flush left the code in the shared container. + // The container is read on launch, so the value outlives the record it was + // copied into: the next launch finds the handoff again and re-attributes + // from it -- including after an erasure, which is the one case where the + // framework has deliberately forgotten and cannot notice that the clip has + // not. + // + // The write side is the one that proves this matters. A clip is a + // short-lived process that can be killed the moment it hands over, and it + // calls synchronize for that reason; the full app is longer-lived but the + // asymmetry has no justification, and the cost here is one flush on a path + // that runs once per install. + // BOTH the flush and the key, because neither alone is evidence. + // + // Reading the key back is not enough on its own, and an earlier version of + // this did exactly that: removeObjectForKey: has already changed this + // NSUserDefaults instance's in-memory view, so objectForKey: answers nil + // whether or not anything reached the disk. The check passed by + // construction and the erasure it gates was never actually verified. + // + // synchronize's BOOL is the half that knows about the disk, so it is what + // says the removal is durable; the key is still read afterwards because a + // successful flush of the wrong thing is not what is being claimed either. + // The caller refuses the erasure on false, and a container that still + // holds a code is exactly what it must refuse on. + BOOL flushed = [suite synchronize]; + BOOL gone = [suite objectForKey:kCN1InviteHandoffKey] == nil; + return (flushed && gone) ? JAVA_TRUE : JAVA_FALSE; +} + +#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_readAppClipInviteHandoff___java_lang_String_R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + return JAVA_NULL; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_lang_String_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + return JAVA_TRUE; +} + +#endif // CN1_INCLUDE_INVITE_APPCLIP 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..a1b27c60267 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java @@ -0,0 +1,142 @@ +/* + * 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 + .readAppClipInviteHandoff(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 + // container is read once and emptied once: splitting the call would + // mean deciding which half clears it. + 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); + } + + /// Empties the shared container, once the framework has the code stored + /// somewhere that survives this process. + /// + /// The read deliberately leaves it alone. This container is the only + /// durable copy of an exact App Clip code until the framework writes its + /// own record, so clearing on read destroyed it whenever that write failed + /// or the process exited in between -- and the next launch, finding no + /// handoff, settled an invited install as no_match for ever. Nothing + /// reports that: the clip ran, the store carried the person across, and + /// the install simply looks organic. + public boolean discardHandoff() { + if (appGroup == null || appGroup.length() == 0) { + // No group, so no container, so nothing is holding a code. + return true; + } + try { + return IOSImplementation.nativeInstance.clearAppClipInviteHandoff(appGroup); + } catch (Throwable t) { + // Reported as NOT discarded, because the caller may be an erasure. + // A container that could not be emptied still holds an exact code + // naming an inviter, and it is read on the next launch -- so the + // honest answer is that the handoff is still there, whatever the + // reason. + Log.e(t); + return false; + } + } + + /// 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..d63f6353059 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -2539,4 +2539,42 @@ 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, WITHOUT clearing it. + * + *

The container is the only durable copy of an exact App Clip code + * until the framework writes its own record, so reading and clearing in + * one step destroyed it whenever that write failed or the process exited + * in between -- and the next launch, finding no handoff, settled an + * invited install as no_match for ever. {@link + * #clearAppClipInviteHandoff(String)} is what empties it, once the code is + * stored.

+ * + * @param appGroup the group identifier + * @return "code\nclickedSeconds", or null when no clip ran + */ + native String readAppClipInviteHandoff(String appGroup); + + /** + * Empties the shared container, once the framework has stored the code. + * + *

Read-once is still the contract the source states: this is the step + * that enforces it, moved to the point where losing the value costs + * nothing.

+ * + * @param appGroup the group identifier + */ + native boolean clearAppClipInviteHandoff(String appGroup); + } diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava010Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava010Snippet.java new file mode 100644 index 00000000000..518b2462bb3 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava010Snippet.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2012, 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.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import com.codename1.analytics.*; +import com.codename1.appreview.*; +import com.codename1.ads.*; +import com.codename1.util.*; +import java.util.*; +import com.codename1.analytics.invite.*; + +class AnalyticsJava010Snippet { + void snippet() { + // tag::analytics-java-010[] + Invite invite = Invites.create(InviteRequest.create() + .campaign("spring") + .channel("share_sheet") + .title("Join me") + .description("I am using this and thought of you.") + .build()); + Invites.share(invite, "Come and try this with me"); + // end::analytics-java-010[] + } +} 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 new file mode 100644 index 00000000000..8f24fc14dca --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava011Snippet.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2012, 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.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import com.codename1.analytics.*; +import com.codename1.appreview.*; +import com.codename1.ads.*; +import com.codename1.util.*; +import java.util.*; +import com.codename1.analytics.invite.*; + +class AnalyticsJava011Snippet { + void snippet() { + // tag::analytics-java-011[] + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution attribution) { + // attribution.getCampaign(), getCode(), getPayload() + 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. + } + } + + public void attributionUnavailable(String reason) { + // The ordinary outcome: most installs are not invited. + } + }); + Invites.checkForInvite(); + // end::analytics-java-011[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava012Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava012Snippet.java new file mode 100644 index 00000000000..38804a1511c --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava012Snippet.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2012, 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.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import com.codename1.analytics.*; +import com.codename1.appreview.*; +import com.codename1.ads.*; +import com.codename1.util.*; +import java.util.*; +import com.codename1.analytics.invite.*; + +class AnalyticsJava012Snippet { + void snippet() { + // tag::analytics-java-012[] + // Once the invited user reaches whatever the invite existed for. + Invites.conversion("signup"); + Invites.conversion("subscribed", 9.99, "USD"); + // end::analytics-java-012[] + } +} diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index efab1cf28ff..d29dff94c2a 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -96,6 +96,9 @@ Codename One emits a few events for you, so the common funnels work without any | `share` | The native share sheet is invoked. + +| `invite_created`, `invite_shared`, `invite_share_dismissed`, `invite_opened`, `invite_install`, `invite_converted` +| The invite funnel, under the `referral` category. See <>. |=== You can add your own events at any time with `Analytics.event(...)`; these built-ins simply save you the boilerplate for the most common funnels. @@ -171,6 +174,89 @@ Implement `AnalyticsProvider`, or extend `AbstractAnalyticsProvider` and overrid The `init(AnalyticsContext)` callback hands you the app name, version, platform, locale and the pseudonymous client id. `onConsentChanged(AnalyticsConsent)` lets a provider reconfigure when the user updates consent. `supports(AnalyticsCapability)` lets tooling introspect which features a provider offers. +[[analytics-invites]] +=== Invite a friend + +`com.codename1.analytics.invite` follows an invitation through to what it caused: who sent it, who installed because of it, and what that invited cohort went on to spend. It replaces what Firebase Dynamic Links and Firebase Invites used to do, both of which have been shut down. + +==== Sending an invite + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava010Snippet.java[tag=analytics-java-010,indent=0] +---- + +`Invites.create` returns immediately and works with no network at all, so the share sheet never waits on a server -- the code is generated on the device and registered with the link service in the background. `title` and `description` drive the preview card the link shows in a messaging app. + +`Invites.share` reports `invite_shared` only when the platform confirms the user shared; a dismissed sheet reports `invite_share_dismissed`. If you send the invite through your own user interface instead, call `Invites.reportShareResult` from your share callback so the funnel still records it. + +==== Receiving an invite + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava011Snippet.java[tag=analytics-java-011,indent=0] +---- + +Call `checkForInvite()` from `start()`. It's 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. An answer that arrives before you register a listener -- which happens routinely on a cold launch from a link -- is held and delivered when you register. + +Exactly one of the two listener methods is called per install, and neither is called again on later launches. + +==== Closing the funnel + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava012Snippet.java[tag=analytics-java-012,indent=0] +---- + +When attribution resolves it's also written as persistent analytics dimensions (`cn1_campaign`, `cn1_channel`, `cn1_invite_code`, `cn1_invite_match`). Every later event carries them, including the `purchase` event the framework already emits for you, so revenue and lifetime value per campaign come out of the reports you already have without any extra wiring. The `cn1_` prefix is reserved -- don't use it for your own dimensions. + +==== How exact the answer is + +`InviteAttribution.getMatchType()` tells you how the attribution was made, and the three answers aren't equally trustworthy: + +[options="header",cols="1,3"] +|=== +| Match type | What it means + +| `MATCH_DIRECT` +| The link opened an app that was already installed. Exact. + +| `MATCH_REFERRER` +| The invite code travelled through the app store and came back verbatim. Exact. This is the Android path. + +| `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. +|=== + +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. + +==== Consent + +Everything reported here is gated on the analytics consent category, and nothing is transmitted until consent is granted. + +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. + +==== What the build does for you + +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'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 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 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 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. + [[analytics-migration]] === Migrating from AnalyticsService 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/** + + + + 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..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 @@ -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; @@ -61,6 +83,72 @@ 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 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(); + assertEquals("spring", loaded.get("cn1_campaign"), + "an upgrading app lost a dimension it set under the old contract"); + assertEquals("pro", loaded.get("plan")); + } + @FormTest void setUserIdRequiresPersonalizationConsent() { Analytics.clearProviders(); 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..caabd2312ff --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -0,0 +1,643 @@ +/* + * 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()); + // 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 + 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 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 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 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 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(); + 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 nothingIsTransmittedBeforeAChoiceAndTheProfileIsDeletedIfRefused() { + // "No choice yet" is null, NOT AnalyticsConsent.none() -- none() is an + // explicit refusal, and this test used to conflate the two. The + // distinction is the whole point: an unanswered prompt still captures + // the profile, because the match window closes long before a user gets + // round to answering. + InviteTestSupport.freshInstall(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "nothing may leave the device before a choice is made"); + assertTrue(Storage.getInstance().exists(InviteStore.PENDING), + "an unanswered prompt must still capture the profile"); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + assertNoProfileHeld("a refused profile must be deleted, not held"); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + } + + @FormTest + void anAlreadyRefusedUserNeverGetsAProfileWrittenAtAll() { + // The earlier hole: the record was created and persisted BEFORE the + // consent check, and onConsentChanged only deletes a record that exists + // when it runs. So a user who had already refused got a profile written + // on the next launch and it stayed there indefinitely -- contradicting + // the documented promise that a refused profile is deleted. + InviteTestSupport.freshInstall(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invites.checkForInvite(); + + assertNoProfileHeld("a refused user must never have a profile written"); + assertEquals(0, implementation.getQueuedRequests().size()); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + } + + @FormTest + void registeringTheProviderBeforeAnyChoiceMustNotLookLikeARefusal() { + // The regression this pins: Analytics.addProvider synthesizes + // AnalyticsConsent.denied() for the null state, and the invite provider + // is registered on every facade entry. A second launch before the user + // has answered the prompt therefore arrived looking exactly like an + // explicit refusal, deleted the profile captured on the first launch, + // and moved to DECLINED -- so a later grant could never resume, for a + // user who had refused nothing. + InviteTestSupport.freshInstall(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(null); + implementation.setAutoProcessConnections(false); + + // First launch captures the deferred profile. + Invites.checkForInvite(); + assertTrue(Storage.getInstance().exists(InviteStore.PENDING)); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + // Second launch, still no choice on record: re-registering the provider + // must leave the profile alone. + Analytics.clearProviders(); + Analytics.addProvider(new InviteAttributionProvider()); + + assertTrue(Storage.getInstance().exists(InviteStore.PENDING), + "an unanswered prompt was treated as a refusal"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + // And a later grant still resolves rather than being stuck at DECLINED. + Analytics.setConsent(AnalyticsConsent.granted()); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + } + + @FormTest + void customParametersSurviveARestart() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution("{\"resolved\":true,\"code\":\"ABC123\"," + + "\"campaign\":\"spring\",\"parameters\":{\"room\":\"42\"}}", + Invites.MATCH_REFERRER, true); + assertEquals("42", Invites.getAttribution().getParameters().get("room")); + + // Simulate the next process: drop the in-memory copy and re-read the + // durable record. An answer that arrives before the listener registers + // is delivered on the NEXT launch, so losing the parameters here means + // delivering an attribution stripped of the data the app acts on. + Invites.forgetCachedAttributionForTest(); + + assertEquals("42", Invites.getAttribution().getParameters().get("room"), + "custom parameters did not survive the restart"); + assertEquals("spring", Invites.getAttribution().getCampaign()); + } + + @FormTest + void aResponseInFlightDuringAnErasureIsDiscarded() { + // An erasure deletes the pending record and clears the dimensions, but + // the request it raced was already on the wire. Resolving it anyway + // wrote the attribution and the referral dimensions straight back -- + // under the freshly issued identity -- undoing exactly what the user + // asked for. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.checkForInvite(); + + int issuedUnder = Invites.currentLookupEpochForTest(); + Analytics.resetClientId(); + + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true, issuedUnder); + + assertNull(Invites.getAttribution(), "an erased identity was re-attributed"); + assertNull(Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN)); + } + + @FormTest + void aResponseInFlightWhenConsentIsWithdrawnIsDiscarded() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.checkForInvite(); + + int issuedUnder = Invites.currentLookupEpochForTest(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true, issuedUnder); + + assertNull(Invites.getAttribution(), "a refusal was overridden by a late response"); + } + + @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"); + } + + /** + * Asserts that no device profile is held, which is not the same as + * asserting the record is absent. + * + *

A refusal has to be durable or the listener is told again on every + * launch, so what remains is a marker carrying the state and the reason and + * nothing else. The promise is about the profile -- the platform, the OS + * version, the model, the screen size, the locale -- and that is what this + * checks. Asserting absence instead made the promise untestable the moment + * it had to survive a relaunch.

+ */ + private void assertNoProfileHeld(String message) { + Map record = InviteStore.read(InviteStore.PENDING); + 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"}) { + 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"); + } + + @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"); + } + + @FormTest + void grantingConsentRestartsALookupThatSuspensionKilled() { + // Switching from OPT_OUT to OPT_IN with nothing on record withdraws the + // mode's implicit allow, so queued requests are killed: they passed the + // permission gate a moment ago and would transmit after transmission + // stopped being permitted. Nothing is refused -- the prompt is simply + // unanswered -- so the lookup stays pending. + // + // The kill used to leave lookupIssuedAt stamped, so for the rest of the + // retry interval the lookup was dead and the state said it was in + // flight. Granting consent inside that interval then did nothing, + // because onConsentChanged() will not restart a lookup it believes is + // already outstanding, and the invite stayed unresolved until an + // explicit check after the delay or the next launch -- by which time + // the attribution window may have closed. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public boolean discardReferrer() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=SUSPEND1", 0L, 0L); + } + }); + Invites.checkForInvite(); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "the fixture never got a lookup under way"); + + // The withdrawal. The retry interval has NOT elapsed, which is the + // whole point: this is the window the stale stamp covered. + Analytics.setConsentMode(ConsentMode.OPT_IN); + implementation.clearQueuedRequests(); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + + assertFalse(implementation.getQueuedRequests().isEmpty(), + "consent was granted while the killed lookup still looked outstanding, " + + "so nothing restarted it and the invite stays unresolved until " + + "the retry interval elapses or the app is launched again"); + } +} 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..e3667a22f0a --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -0,0 +1,414 @@ +/* + * 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.ConsentMode; +import com.codename1.analytics.AnalyticsConsent; +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; +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.assertFalse; +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 boolean discardReferrer() { + 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 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); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public boolean discardReferrer() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + } + }); + + Invites.checkForInvite(); + + assertTrue(InviteTestSupport.pendingHandoff.wasAsked(), + "the referrer came back empty and nothing asked the App Clip"); + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + 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(); + 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(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 + 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 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 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 + // 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(); + 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..eb4a9e44260 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java @@ -0,0 +1,315 @@ +/* + * 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.security.Hash; +import com.codename1.util.Base64; +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.fail; +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(Invites.isRegistered(invite), + "nothing has acknowledged it yet"); + } + + @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 anUnacknowledgedRegistrationStaysInTheOutboxAndIsRetried() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create().campaign("spring").build()); + assertEquals(1, implementation.getQueuedRequests().size()); + + // Nothing has answered, so the entry must survive: the registration + // carries the campaign and payload, and a click cannot reconstruct + // them. The offline mint is exactly the case this protects. + implementation.clearQueuedRequests(); + Invites.flush(); + + List retried = implementation.getQueuedRequests(); + boolean reposted = false; + for (ConnectionRequest r : retried) { + if (r.getUrl().endsWith("/api/v2/analytics/invites") + && r.getRequestBody().contains(invite.getCode())) { + reposted = true; + } + } + assertTrue(reposted, "an unacknowledged registration was dropped"); + assertFalse(Invites.isRegistered(invite)); + } + + @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()); + } + @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"); + } + + @FormTest + void theCodeIsTheTruncatedDigestOfTheProof() { + // The CONTRACT with the server, pinned on both sides against the same + // vector. The server accepts a registration for an unknown code only + // when the proof digests to it, so a disagreement about the digest, + // the alphabet, the padding or the truncation refuses every mint -- + // safe, and not a failure anybody would enjoy diagnosing from either + // repository alone. InviteService.provesCreation has the twin of this. + byte[] secret = new byte[16]; + for (int i = 0; i < secret.length; i++) { + secret[i] = (byte) (i + 1); + } + String proof = Base64.encodeUrlSafe(secret); + String digest = Base64.encodeUrlSafe(Hash.sha256(secret)); + + assertEquals("AQIDBAUGBwgJCgsMDQ4PEA", proof, + "the proof encoding drifted from the one the server decodes"); + assertEquals("Xfur7t8xi_M8CSfEPXYw9R", digest.substring(0, 22), + "the code derivation drifted from the one the server verifies"); + } + + @FormTest + void aMintedCodeIsNotItsOwnProof() { + // The whole point: the code is public -- it is in the share url -- and + // must not be enough to register itself. Before this, an invite shared + // while its registration sat in the offline outbox could be registered + // by whoever was sent the link, and every install and payout on it + // went to them. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create().campaign("spring").build()); + + assertEquals(22, invite.getCode().length(), + "the code length changed, which the server truncates to"); + boolean carriesProof = false; + for (ConnectionRequest r : implementation.getQueuedRequests()) { + String body = r.getRequestBody(); + if (body != null && body.contains("\"proof\"")) { + carriesProof = true; + assertFalse(body.contains("\"proof\":\"" + invite.getCode() + "\""), + "the proof is the code, so anyone holding the link can register it"); + } + } + assertTrue(carriesProof, + "the registration carried no proof, so the server cannot tell the " + + "minter from anyone who was sent the link"); + assertFalse(invite.getUrl().contains("AQIDBAUGBwgJCgsMDQ4PEA"), + "the url carries a proof"); + } + + @FormTest + void anInviteCannotCarryUnboundedFieldsIntoTheOutbox() { + // The registration json is persisted in the outbox BEFORE anything is + // sent and before any server sees it. The outbox caps its entry COUNT, + // which bounds nothing if one entry can be any size -- so an + // unbounded image address, or a parameter map built in a loop, went + // straight to storage. Payload, title and description were already + // refused at build(); these two were the way past all of them. + StringBuilder huge = new StringBuilder("https://example.com/"); + for (int i = 0; i < InviteRequest.MAX_IMAGE_URL_LENGTH; i++) { + huge.append('x'); + } + try { + InviteRequest.create().imageUrl(huge.toString()).build(); + fail("an image address longer than the limit was accepted and would be " + + "written to storage"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("imageUrl"), expected.getMessage()); + } + + InviteRequest.Builder many = InviteRequest.create(); + for (int i = 0; i <= InviteRequest.MAX_PARAMETERS; i++) { + many.param("k" + i, "v"); + } + try { + many.build(); + fail("an unbounded parameter map was accepted"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("parameters"), expected.getMessage()); + } + + StringBuilder bigValue = new StringBuilder(); + for (int i = 0; i <= InviteRequest.MAX_PARAM_VALUE_LENGTH; i++) { + bigValue.append('y'); + } + try { + InviteRequest.create().param("note", bigValue.toString()).build(); + fail("an unbounded parameter value was accepted"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("note"), expected.getMessage()); + } + } + + @FormTest + void anInviteWithinTheLimitsIsStillAccepted() { + // The bound must not refuse the ordinary case it exists to cap. + InviteRequest r = InviteRequest.create() + .imageUrl("https://example.com/preview.png") + .param("tier", "gold") + .build(); + assertNotNull(r, "an ordinary invite was refused by the new bounds"); + } +} 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 new file mode 100644 index 00000000000..fb06cdfe05c --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -0,0 +1,2678 @@ +/* + * 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.ui.Display; +import com.codename1.junit.EdtTest; +import com.codename1.junit.FormTest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import com.codename1.junit.UITestBase; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.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; + +/** + * What happens when the server, the storage or the process does not cooperate. + * Every case here is a way the feature used to lose data quietly rather than + * loudly, so each one asserts the durable record rather than a return value. + */ +class InviteResilienceTest extends UITestBase { + + @BeforeEach + void setUp() { + InviteTestSupport.freshInstall(); + } + + @AfterEach + void tearDown() { + InviteTestSupport.tearDown(); + } + + @Test + @EdtTest + void aServerErrorDoesNotRetireTheRegistration() { + // ConnectionRequest reads error bodies by default and then runs the + // ordinary success path over them, so a 503 reached postResponse() + // exactly as a 200 did and the outbox entry -- the only durable copy of + // the campaign, channel, payload and preview of a link already handed + // out -- was discarded as though the server had accepted it. + String entry = "{\"code\":\"abc123\",\"campaign\":\"launch\"}"; + InviteStore.writeOutbox(new ArrayList(Arrays.asList(entry))); + + Invites.InviteConnection req = new Invites.InviteConnection( + Invites.MATCH_DIRECT, false, true, entry, 0); + req.handleErrorResponseCode(503, "Service Unavailable"); + req.postResponse(); + + assertEquals(Arrays.asList(entry), InviteStore.readOutbox(), + "a 503 retired the registration as though it had been accepted"); + } + + @Test + @EdtTest + void aServerErrorIsNotReadAsAnOrganicInstall() { + // The same fall-through, on the lookup side: an error body parses to + // nothing that says "resolved", which the resolution path treats as a + // terminal "you were not invited" -- turning one bad minute on the + // server into a permanent wrong answer on the device. + Invites.checkForInvite(); + Invites.InviteConnection req = new Invites.InviteConnection( + Invites.MATCH_DIRECT, true, false, null, Invites.currentLookupEpochForTest()); + req.handleErrorResponseCode(500, "Internal Server Error"); + // The error body really is read -- that is the whole mechanism -- so + // the test has to supply one. An error page carries no "resolved", and + // the resolution path reads that as a settled negative. + try { + req.readResponse(new ByteArrayInputStream( + "{\"error\":\"upstream unavailable\"}".getBytes("UTF-8"))); + } catch (IOException e) { + throw new IllegalStateException(e); + } + req.postResponse(); + + assertFalse(Invites.getState() == Invites.STATE_NONE_FOUND, + "a server fault was recorded as a settled negative answer"); + } + + @Test + @EdtTest + void aNoMatchAnswerIsNotAskedAgainOnTheNextLaunch() { + // Deleting the pending record was not enough: an absent record reads + // back as STATE_NONE, so the next launch built a fresh profile and + // queried again, and an ordinary uninvited install kept contacting the + // server for ever. + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "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"); + } + + @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 + // 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.outstandingRequestCountForTest() <= 32, + "queued registrations accumulated without bound: " + + Invites.outstandingRequestCountForTest()); + } + + @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 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 + // 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 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, + // 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 + // 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 aDurableClipHandoffIsAcknowledged() { + // The shared container is the only durable copy of an exact App Clip + // code until this record is written, so the source is told when the + // framework has it and may let go. Without the acknowledgement the + // container is read again on every launch. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + Invites.checkForInvite(); + assertTrue(source.wasAsked(), "the fixture never reached the clip handoff"); + + source.answer("CLIPACK1", 1700000000L); + + assertEquals(1, source.discardedCount(), + "a durable handoff was never acknowledged"); + } + + @FormTest + void aClipHandoffWhoseRecordFailedIsNotAcknowledged() { + // And the half that matters: a source that empties the container on + // being told would destroy the exact code, because the write that was + // supposed to keep it did not land. The next launch then finds no + // handoff and settles an invited install as no_match for ever. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + Invites.checkForInvite(); + assertTrue(source.wasAsked(), "the fixture never reached the clip handoff"); + + // Every write of the record fails, retries included. One failure is + // not enough to show this: claim() reads the record on its way out and + // readPending() persists the held copy, so a single shot leaves it + // durable -- which is the case the test below covers. + InviteStore.failWritesForTest(InviteStore.PENDING, 8); + source.answer("CLIPACK2", 1700000000L); + + assertTrue(Invites.pendingFallbackPresentForTest(), + "the record was saved after all, so this proves nothing"); + assertEquals(0, source.discardedCount(), + "the source was told to discard the only copy of the code while the " + + "write that was supposed to keep it kept failing"); + } + + @FormTest + void anAcknowledgementThatFailedIsRetriedByTheNextWrite() { + // The obligation is cleared only when the copy really went. It used to + // be dropped before the answer was read, so a removal the container + // refused -- or a flush that never reached the disk, which is what the + // native side now reports -- counted as done. The code then sat in the + // shared container for good: nothing asked again, and the container is + // read on launch, so it returns if the framework's record is ever lost. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + Invites.checkForInvite(); + assertTrue(source.wasAsked(), "the fixture never reached the clip handoff"); + + source.discardFails = true; + source.answer("ACKRETRY1", 1700000000L); + assertTrue(source.discardedCount() > 0, + "the fixture never attempted a discard, so this proves nothing"); + + // The container still holds it, so the next durable write of THIS + // record must ask again rather than assume. That write is the claim + // retry: it bumps the attempt count and saves the same app_clip + // record, which is what the acknowledgement hangs off. + int attempted = source.discardedCount(); + source.discardFails = false; + Invites.lookupRetryDelay = 0; + Invites.checkForInvite(); + + assertTrue(source.discardedCount() > attempted, + "a discard the container refused was treated as done, so the code " + + "stays in the shared container and nothing ever asks again"); + } + + @FormTest + void aHandoffOnlyFailureStillLatchesTheErasure() { + // resetVerified() reports the failure, and reset() decides whether to + // LATCH by asking what survived -- which was answered by reading the + // three InviteStore records, all of which had gone. So the one failure + // whose only survivor sits outside our storage latched nothing: no + // durable marker, nothing blocked, nothing retrying, and the surviving + // code read by the next launch. The whole point of the gate. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + source.discardFails = true; + + Invites.reset(); + + assertNotNull(InviteStore.read(InviteStore.ERASURE), + "a reset whose only survivor was the App Clip container reported " + + "success and left nothing retrying, so the next launch " + + "restores the attribution it promised to forget"); + } + + @FormTest + void aHandoffThatWillNotGoFailsTheReset() { + // Every store deletion in resetVerified() is gated; the clip container + // was not, so a container that refused to empty -- or a flush that did + // not reach the disk -- let reset() report success with an exact code + // still on the device. The next launch reads it and re-attributes, + // which is precisely what the erasure promised would not happen. + // + // Failing is the right answer rather than the tidy one: reset() + // latches and retries, so the code is tried again, and nothing tells + // the user their attribution is gone while it is not. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + source.discardFails = true; + + assertFalse(Invites.resetVerified(), + "an erasure reported success while the App Clip container still held " + + "the code, so the next launch restores the attribution it " + + "promised to forget"); + + source.discardFails = false; + assertTrue(Invites.resetVerified(), + "the erasure kept failing once the container could be emptied"); + } + + @FormTest + void forgettingDiscardsAHandoffNothingHasReadYet() { + // The acknowledgement paths all cover a code THIS process read. A code + // the clip left that nothing has consumed is still in the shared + // container, and reset() -- or an erasure -- used to clear the store + // and leave it there. + // + // The container is read on launch, so the next check finds it and + // attributes the device to exactly the inviter that was erased; until + // then the raw code sits on disk naming them. Forgetting has to reach + // it, which means asking the source without any record to go on. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + assertEquals(0, source.discardedCount(), + "the fixture starts from a discard, so the count below proves nothing"); + + // No checkForInvite(): nothing has read the handoff, which is the case. + Invites.reset(); + + assertEquals(1, source.discardedCount(), + "an unconsumed handoff survived the reset, so the clip's container " + + "still holds a code naming the inviter and the next launch " + + "re-attributes the device to them"); + } + + @FormTest + void aClipHandoffAcknowledgedWhenTheRETRYPersistsIt() { + // The other end of the same obligation. A failed write leaves the + // record in memory and readPending() retries it the next time anything + // wants it -- so the code became durable through a path that never + // told the clip, and its container kept the code for ever. + // + // That outlives an erasure: the container is read on launch, so the + // next one found the handoff again and restored exactly the + // attribution the user asked to be forgotten. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + Invites.checkForInvite(); + assertTrue(source.wasAsked(), "the fixture never reached the clip handoff"); + + // Exactly ONE write fails. The held record is then persisted by the + // retry inside readPending(), which claim() reaches on its way out -- + // the path that used to make the code durable with nobody telling the + // clip. + InviteStore.failNextWriteForTest(InviteStore.PENDING); + source.answer("CLIPRETRY", 1700000000L); + + assertFalse(Invites.pendingFallbackPresentForTest(), + "the retry did not persist the held record, so this proves nothing"); + assertEquals(1, source.discardedCount(), + "the record became durable through the retry and the clip was never " + + "told, so its container keeps the code and a launch after an " + + "erasure restores the attribution that was erased"); + } + + @FormTest + void anAnswerThatSettlesNothingIsThrottledToo() { + // A 2xx whose body is empty, unparseable, or resolved with no code + // reaches a plain return without settling anything -- and the entry to + // handleResolution used to clear the in-flight stamp for EVERY + // response. The lookup was then pending with nothing to throttle it, + // so each later checkForInvite() re-issued at once and burned another + // of the five durable attempts: a couple of lifecycle calls could + // settle an exact code as no_match in seconds. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public boolean discardReferrer() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=USELESS1", 0L, 0L); + } + }); + Invites.checkForInvite(); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "the fixture never got a lookup under way"); + + // The useless answer: a success that decides nothing. + Invites.handleResolution("", Invites.MATCH_REFERRER, true); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "an empty body settled the lookup, so this proves nothing"); + implementation.clearQueuedRequests(); + + // The retry interval has NOT elapsed, so these must do nothing. + Invites.checkForInvite(); + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "an answer that settled nothing left the lookup unthrottled, so every " + + "later check re-asked at once and spent the attempt budget"); + } + + @FormTest + void aNotYetAnswerIsNotAskedAgainImmediately() { + // The retry above must be throttled, or the fix for it becomes its own + // bug: every response clears the issued-at stamp, so with the state + // left pending an application that calls checkForInvite() from two + // places would re-issue on each one, spend all five attempts in + // seconds, and settle an offline-minted invite as no_match before its + // registration ever arrived. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public boolean discardReferrer() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=THROTTLE", 0L, 0L); + } + }); + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false,\"retry\":true}", + Invites.MATCH_REFERRER, true); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + implementation.clearQueuedRequests(); + + // The retry interval has NOT elapsed, so these must do nothing. + Invites.checkForInvite(); + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "a not-yet answer was re-asked inside the retry interval, which is how " + + "the attempt budget is spent in seconds"); + } + + @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 boolean discardReferrer() { + 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() { + // 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() { + // 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_APP_CLIP, true); + Map marker = InviteStore.read(InviteStore.PENDING); + assertNotNull(marker, "the answer has to be durable"); + 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 + @EdtTest + void aPendingReattributionSurvivesAProcessRestart() { + // With re-attribution on, a later invite writes a new claim while the + // earlier attribution still stands. Answering STATE_RESOLVED from the + // old attribution made beginDeferred() return, so a claim interrupted + // by process death was never retried and last touch lost to first. + Invites.handleResolution(InviteTestSupport.resolvedJson("first", "c1", "sms"), + Invites.MATCH_DIRECT, false); + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/second"); + Invites.forgetLoadedState(); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "the pending re-attribution claim was lost behind the old attribution"); + } + + @Test + @EdtTest + void withoutReattributionAStalePendingRecordDoesNotReopenAnAttribution() { + // The other half of the same rule, and the reason the check is scoped: + // first touch stands, so a leftover pending record must never unsettle + // an attribution that has already resolved. + Invites.checkForInvite(); + Invites.handleResolution(InviteTestSupport.resolvedJson("first", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + } + + @Test + @EdtTest + void aFailedOutboxWriteIsReportedRatherThanSwallowed() { + // The caller has to be able to tell, because an entry that never + // reached the outbox is a registration nothing can reconstruct. + List ok = new ArrayList(Arrays.asList("{\"code\":\"a\"}")); + assertTrue(InviteStore.writeOutbox(ok), "a healthy store must report success"); + assertEquals(ok, InviteStore.readOutbox()); + } + + @Test + @EdtTest + void aDisabledAttributionWindowIsAnsweredOnceAndSurvivesARelaunch() { + // setState() only rewrites a record that already exists, and on a fresh + // install none does -- so this answer lived only in memory and the + // listener heard it again on every launch. + 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]++; + } + }); + Invites.checkForInvite(); + assertEquals(1, told[0]); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + Invites.forgetLoadedState(); + Invites.checkForInvite(); + assertEquals(1, told[0], "the listener was told again after a relaunch"); + } + + @Test + @EdtTest + void reenablingTheWindowReopensThatOneTerminalMarker() { + // The disabled-window marker is the only terminal answer that can stop + // being true, so it is the only one that is reopened. An application + // that ships a non-zero window later is asking for attribution again. + Invites.setAttributionWindow(0); + Invites.checkForInvite(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + Invites.setAttributionWindow(Invites.DEFAULT_ATTRIBUTION_WINDOW); + Invites.forgetLoadedState(); + 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 + @EdtTest + void aNoMatchDoesNotSettleTheInstallWhileAReferrerRetryIsOutstanding() { + // The Play referrer failed transiently, so the source deliberately left + // its once-only flag unset and a later launch can still read the exact + // referrer. Settling the install as organic on the statistical + // fallback's answer would throw that deterministic result away. + Invites.checkForInvite(); + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending); + pending.put("referrerRetry", "true"); + InviteStore.write(InviteStore.PENDING, pending); + + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); + + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a transient store outage settled the install as organic"); + } + + @Test + @EdtTest + void aDirectlySentRegistrationIsNotRegisteredUntilItIsAcknowledged() { + // Absence from the outbox is not acknowledgement. When the store cannot + // be written the registration is sent directly and never queued, so the + // outbox says nothing about it -- and reading that silence as success + // reported an in-flight, possibly failed, registration as acknowledged. + Invite invite = Invites.create(InviteRequest.create().campaign("launch").build()); + assertNotNull(invite); + // Exactly what a failed enqueue leaves behind: nothing in the durable + // queue, and a request on the wire. The outbox is emptied to stand in + // for the write that did not happen. + InviteStore.writeOutbox(new ArrayList()); + Invites.markSentDirectlyForTest(invite.getCode()); + assertFalse(Invites.isRegistered(invite), + "an unacknowledged direct send reported itself as registered"); + } + + @Test + @EdtTest + void aDirectLinkSupersedesADeferredLookupAlreadyOnTheWire() { + // Both requests used to be issued under the same epoch, so both answers + // passed the guard and a statistical match arriving second overwrote + // the exact one -- dimensions and durable record included. + Invites.checkForInvite(); + int deferredEpoch = Invites.currentLookupEpochForTest(); + + Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT1"); + Invites.handleResolution(InviteTestSupport.resolvedJson("DIRECT1", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + // The deferred answer arrives late, under the epoch it was issued in. + Invites.handleResolution(InviteTestSupport.resolvedJson("GUESS", "c2", "unknown"), + Invites.MATCH_APP_CLIP, true, deferredEpoch); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a); + assertEquals("DIRECT1", a.getCode(), + "a late statistical match overwrote the exact direct attribution"); + } + + @Test + @EdtTest + void aPendingReferrerRetryTellsTheListenerNothing() { + // attributionUnavailable() is the terminal callback and this outcome is + // the opposite of terminal. It also sets deliveredThisRun, so a + // referrer that succeeded moments later could no longer deliver + // inviteReceived() at all. + Invites.checkForInvite(); + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending); + pending.put("referrerRetry", "true"); + InviteStore.write(InviteStore.PENDING, pending); + + final int[] told = new int[1]; + final int[] received = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0]++; + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + 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. + Invites.handleResolution(InviteTestSupport.resolvedJson("LATE1", "c1", "sms"), + Invites.MATCH_REFERRER, true); + assertEquals(1, received[0], "the later exact referrer result was suppressed"); + } + + @Test + @EdtTest + void aDefinitiveReferrerAnswerClearsTheRetryMarker() { + // An outage set the marker; a later successful read that carries no + // invite is definitive and must clear it, or the following no-match + // looks retryable for ever. + Invites.checkForInvite(); + Map pending = InviteStore.read(InviteStore.PENDING); + pending.put("referrerRetry", "true"); + InviteStore.write(InviteStore.PENDING, pending); + + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public boolean discardReferrer() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=organic", 0L, 0L); + } + }); + Invites.reset(); + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); + + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "a stale retry marker kept a definitive organic answer pending"); + } + + @Test + @EdtTest + void aRefusalIsDurableAndIsReopenedByALaterGrant() { + // The refusal was in memory only, so the listener heard it again on + // every launch; making it durable must not make it permanent, because + // granting consent afterwards is a real answer too. + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_DECLINED, Invites.getState(), + "the refusal did not survive a relaunch"); + + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.forgetLoadedState(); + Invites.checkForInvite(); + 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(); + + // 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); + + Invites.handleResolution(InviteTestSupport.resolvedJson("GUESS", "c2", "unknown"), + Invites.MATCH_APP_CLIP, 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"); + } + + @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 boolean discardReferrer() { + 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 boolean discardReferrer() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=LATER1", 0L, 0L); + } + }); + // After the retry interval, which now applies to this path too: a + // transient referrer failure leaves the lookup PENDING, and an + // unthrottled pending lookup re-bound the Play service on every + // lifecycle call without bound, because these local attempts do not + // bump the persisted claim counter. What the case is about is that the + // answer is not settled as organic and still lands when the store + // recovers -- not how soon the retry is allowed. + Invites.lookupRetryDelay = 0; + 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 boolean discardReferrer() { + 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"); + } + + @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"); + } + + @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"); + } + + @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() { + // 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 boolean discardReferrer() { + 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"); + } + + @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 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 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 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 + // 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() { + // 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 boolean discardReferrer() { + 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_APP_CLIP, 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"); + } + + @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"); + } + + @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 boolean discardReferrer() { + 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 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() { + // 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_APP_CLIP, true); + + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + Invites.setInviteListener(l); + 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 boolean discardReferrer() { + 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 boolean discardReferrer() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + spent = true; + callback.onUnavailable(Invites.REASON_NO_MATCH); + } + }); + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); + + Invites.forgetLoadedState(); + 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"); + } + + @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 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 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() { + // 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 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() { + // 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_APP_CLIP, 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_APP_CLIP, 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"); + } + + @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"); + } + + @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"); + + // 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: + // 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"); + } + + @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"); + } + + @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"); + // 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 + @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 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 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() { + // 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"); + } +} 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..dfed816f7a0 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.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.Analytics; +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 + * 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() { + } + + /** 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; + } + + /** Counts the discards, which is what the iOS source clears on. */ + private int discarded; + + /// Set by a test that wants the container to refuse to empty, which + /// is what an erasure has to notice. + boolean discardFails; + + public boolean discardHandoff() { + discarded++; + return !discardFails; + } + + /** How many times the framework said it was done with the handoff. */ + int discardedCount() { + return discarded; + } + + /** 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) { + 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, clickedSeconds); + } + } + + /** 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(); + 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); + // 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. + Invites.lookupRetryDelay = 30000L; + // Any unspent write failure a previous case armed is disarmed here. + // It used to disarm itself, because one failure consumed it; a case + // that asks for several can leave a count behind, and a store that + // refuses to save in a test that never asked for it is a confusing + // way to fail. + InviteStore.failWritesForTest(null, 0); + Invites.reset(); + // Registered AFTER the reset, which now tells the source to discard + // whatever the clip left -- forgetting has to reach a handoff nothing + // has read yet. Registering first counted that discard against the + // fixture and made every case start from one. + pendingHandoff = new PendingHandoffSource(); + Invites.registerAppClipHandoffSource(pendingHandoff); + Preferences.delete(Invites.PREF_SLUG); + // 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() { + clearAppArg(); + 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); + } + + // 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\":\"" + + 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..a5fe425232f --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -0,0 +1,258 @@ +/* + * 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 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.fail; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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")); + } + + @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)); + } + + @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)); + } + + @FormTest + void aForeignUrlCarryingTheKeyIsNotAnInvite() { + // The query form used to be read BEFORE the host was checked and + // returned the moment it found the key, so any deep link the + // application handles for any other domain -- a partner site, a + // campaign page -- was accepted and claimed. That hands a fresh + // install, or a last-touch re-attribution, to whoever wrote a url this + // app happens to open. + assertNull(Invites.extractCode( + "https://partner.example.com/promo?cn1_invite=STOLEN1"), + "a url on somebody else's host was accepted as an invite"); + // Our own host in the query form is still an invite. + assertEquals("MINE123", Invites.extractCode( + "https://cloud.codenameone.com/anything?cn1_invite=MINE123")); + } + + @FormTest + void anotherAppsSlugOnTheSharedHostIsNotOurInvite() { + // One domain serves every enrolled app, which is why the path carries + // a slug. A build whose App Links filter claims /i/ broadly is handed + // /i/other-app/CODE as readily as its own, and this took the last + // component regardless -- claiming a stranger's invite, and + // remembering their slug as its own so later mints advertised their + // links. + Invites.reset(); + Preferences.set(Invites.PREF_SLUG, "acme"); + + assertNull(Invites.extractCode("https://cloud.codenameone.com/i/other-app/THEIRS1"), + "an invite belonging to another app on the shared host was claimed"); + assertEquals("acme", Preferences.get(Invites.PREF_SLUG, ""), + "the foreign slug was remembered, so later invites mint their links"); + assertEquals("OURS123", + Invites.extractCode("https://cloud.codenameone.com/i/acme/OURS123"), + "our own slugged invite stopped being recognised"); + } + + @FormTest + void aRoutedInviteUrlIsNotHandledTwice() { + // handleUrl() is the documented route for an app that handles its own + // deep links, and on Android it runs from the dispatch that setting + // AppArg fires -- with a checkForInvite() queued behind it as the + // fallback for apps with no router. The argument stayed set, so that + // queued check read the same url and handled it again: invite_opened + // twice on a resolved install, and on a pending one a duplicate claim + // whose epoch bump discarded the answer to the first. + Invites.reset(); + com.codename1.ui.Display d = com.codename1.ui.Display.getInstance(); + d.setProperty("AppArg", "https://cloud.codenameone.com/i/ROUTED1"); + + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/ROUTED1"), + "the fixture url was not recognised as an invite"); + + assertNull(d.getProperty("AppArg", null), + "the routed url was left in AppArg, so the queued checkForInvite() " + + "handles the same invite a second time"); + } + + @FormTest + void anUnrelatedAppArgIsLeftAlone() { + // An application may pass any string to handleUrl(). Clearing a launch + // argument that is not the one being handled is not ours to do. + Invites.reset(); + com.codename1.ui.Display d = com.codename1.ui.Display.getInstance(); + d.setProperty("AppArg", "myapp://somewhere/else"); + + Invites.handleUrl("https://cloud.codenameone.com/i/OTHER1"); + + assertEquals("myapp://somewhere/else", d.getProperty("AppArg", null), + "an unrelated launch argument was cleared"); + } + + @FormTest + void theLinkBaseMustBeAnHttpsOrigin() { + // Invite.getUrl() promises an absolute https url, and the generated + // Android filter and iOS associated domain match an https host with + // the /i/ path and nothing else. + Invites.reset(); + // A bare host is what the build hint carries, so it is accepted and + // read as https. + Invites.setLinkBase("links.example.com"); + assertEquals("https://links.example.com", Invites.getLinkBase()); + + try { + Invites.setLinkBase("http://links.example.com"); + fail("an http base was accepted, and every link it mints opens the browser"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("https"), expected.getMessage()); + } + try { + // Mints /base/i/, which the generated filter -- matching + // /i/ -- never sees, while the host check stays silent because + // the host is right. + Invites.setLinkBase("https://links.example.com/base"); + fail("a base carrying a path was accepted"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("no path"), expected.getMessage()); + } + // A trailing slash is not a path. + Invites.setLinkBase("https://links.example.com/"); + assertEquals("https://links.example.com", Invites.getLinkBase()); + Invites.setLinkBase(null); + } + + @FormTest + void onlyHttpsUrlsCarryInvites() { + // An application forwarding its broader deep links here could hand + // over a custom scheme or plain http on the right host, and a + // host-only test accepted both -- persisting and claiming a code + // although nothing the framework mints or the platforms associate is + // anything but https. + assertNull(Invites.extractCode("myapp://cloud.codenameone.com/i/SCHEME1"), + "a custom-scheme url was accepted as an invite"); + assertNull(Invites.extractCode("http://cloud.codenameone.com/i/PLAIN1"), + "an http url was accepted as an invite"); + assertEquals("REAL123", + Invites.extractCode("https://cloud.codenameone.com/i/REAL123"), + "the https form stopped being recognised"); + } +} 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; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java new file mode 100644 index 00000000000..73c9a2b36e5 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java @@ -0,0 +1,244 @@ +/* + * 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.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; +import com.codename1.analytics.invite.Invite; +import com.codename1.analytics.invite.Invites; +import com.codename1.junit.FormTest; +import com.codename1.share.ShareResult; +import com.codename1.ui.events.ActionEvent; +import com.codename1.share.ShareResultListener; +import com.codename1.junit.UITestBase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * A press mints the invite it is about to share, and a second press before the + * first sheet has answered must not mint another. + */ +public class InviteButtonMintTest extends UITestBase { + + @FormTest + void aSecondPressBeforeTheFirstResultSharesTheSameInvite() { + // The share sheet is modal, so a double tap does not open two of them. + // Minting on every press left the first code registered, counted as + // invite_created, and shared by nobody -- and the result, when it + // arrived, was reported against whichever invite the field held by + // then rather than the one whose url went into the sheet. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + InviteButton button = new InviteButton("Invite a friend"); + button.setCampaign("spring"); + + Invite first = button.mintForShare(); + Invite second = button.mintForShare(); + + assertNotNull(first, "the first press minted nothing"); + assertSame(first, second, + "a second press minted another invite, so one of them is shared by " + + "nobody and the result can be reported against the wrong code"); + } + + @FormTest + void theInviteSurvivesTheResultSoTheAppCanCorrelateIt() { + // getInvite() is the application's only way to tell which invite a + // ShareResult belongs to, and its contract is "the invite minted for + // the most recent press". Clearing the field when the result arrived + // -- to stop the next press reporting the same outcome twice -- made + // it answer null from inside the application's own listener, which is + // the single moment it has to be right. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + InviteButton button = new InviteButton("Invite a friend"); + + Invite shared = button.mintForShare(); + final Invite[] seenByTheApp = new Invite[1]; + button.setShareResultListener(new ShareResultListener() { + @Override + public void onResult(ShareResult result) { + seenByTheApp[0] = button.getInvite(); + } + }); + button.chain.onResult(ShareResult.sharedTo("com.example.chat")); + + assertSame(shared, seenByTheApp[0], + "getInvite() answered null inside the app's listener, so the app " + + "cannot tell which invite the ShareResult belongs to"); + assertSame(shared, button.getInvite(), + "getInvite() stopped reporting the most recent press after the result"); + } + + @FormTest + void theNextPressAfterAResultMintsAFreshInvite() { + // The other half of the same split: the outstanding mark must clear on + // the result even though getInvite() keeps answering, or a press after + // a completed share would re-share the code that was already sent and + // report its outcome a second time. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + InviteButton button = new InviteButton("Invite a friend"); + + Invite first = button.mintForShare(); + button.chain.onResult(ShareResult.sharedTo("com.example.chat")); + Invite second = button.mintForShare(); + + assertNotNull(second, "the press after a completed share minted nothing"); + assertNotSame(first, second, + "a press after the sheet had already answered re-shared the invite " + + "that was just sent, so its outcome is reported twice"); + } + + @FormTest + void aSecondPressWhileTheSheetIsOutstandingDoesNothing() { + // Reusing the code was only half of it. ShareButton defers to the next + // EDT cycle and then shares unconditionally, so two presses inside one + // cycle still enqueued two presentations: two native sheets attempted, + // the app's listener called twice, and -- because the first result + // takes the outstanding mark -- the SECOND share reported to nobody. + // A real share missing from the funnel is the part the reuse caused. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + final int[] presented = new int[1]; + InviteButton button = new InviteButton("Invite a friend") { + @Override + void presentShare(ActionEvent evt) { + // NOT delegated: ShareButton would defer to the next EDT cycle + // and open a sheet. Counting here is what the press does, and + // it is the thing the guard changes. + presented[0]++; + } + }; + + button.actionPerformed(new ActionEvent(button)); + button.actionPerformed(new ActionEvent(button)); + + assertNotNull(button.getInvite(), "the first press minted nothing"); + assertEquals(1, presented[0], + "a second press while the sheet was still outstanding presented " + + "another share, so two sheets are attempted, the app's " + + "listener is called twice, and the second share -- whose " + + "outstanding mark the first result already took -- is " + + "reported to nobody"); + } + + @FormTest + void aShareThatNeverReportsDoesNotKillTheButton() { + // The guard must not be keyed on an outstanding share. Display.share() + // documents that the listener always runs, and on Android it does not: + // the API 22+ chooser callback fires only when a target is picked, + // because "Android does not expose a dismissal signal for the chooser" + // (AndroidImplementation.buildShareChooserWithCallback). A user who + // opens the sheet and backs out reports NOTHING, and a guard waiting + // for that report would leave the button dead until the form was + // rebuilt -- a far worse bug than the double presentation it fixes. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + final int[] presented = new int[1]; + InviteButton button = new InviteButton("Invite a friend") { + @Override + void presentShare(ActionEvent evt) { + presented[0]++; + } + }; + + button.actionPerformed(new ActionEvent(button)); + // The cancellation: no result, ever. Only an EDT cycle passes. + pumpEdt(); + button.actionPerformed(new ActionEvent(button)); + + assertEquals(2, presented[0], + "a cancelled share left the button unable to share again, which on " + + "Android is every user who opens the sheet and backs out"); + } + + /// Lets the runnables a press queued run, which is what the next EDT cycle + /// does on a device. + private static void pumpEdt() { + final boolean[] done = new boolean[1]; + com.codename1.ui.Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + done[0] = true; + } + }); + for (int i = 0; i < 50 && !done[0]; i++) { + com.codename1.ui.Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(5); + } catch (InterruptedException e) { + // nothing to do + } + } + }); + } + } + + @FormTest + void aPressAfterTheSheetAnsweredWorksAgain() { + // The guard must not be a latch: swallowing every later press would + // make the button dead after one share. Safe to swallow at all only + // because Display.share() always reports an outcome -- with a null + // package name where the platform cannot say -- so the outstanding + // mark is always cleared. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + final int[] presented = new int[1]; + InviteButton button = new InviteButton("Invite a friend") { + @Override + void presentShare(ActionEvent evt) { + presented[0]++; + } + }; + + button.actionPerformed(new ActionEvent(button)); + Invite first = button.getInvite(); + button.chain.onResult(ShareResult.sharedTo("com.example.chat")); + // A result cannot arrive in the cycle that presented the sheet, so the + // press that follows one is always in a later cycle. + pumpEdt(); + button.actionPerformed(new ActionEvent(button)); + Invite second = button.getInvite(); + + assertEquals(2, presented[0], + "the guard is a latch: the press after a completed share never " + + "reached the share sheet"); + + assertNotNull(second, "the button was dead after one completed share"); + assertNotSame(first, second, + "the press after a completed share did not mint a fresh invite"); + } +} 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..4563d153278 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,43 @@ 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: the invite code makes the + // whole round trip through the store and comes back verbatim, so + // nothing has to be matched or guessed. + // + // Keyed on the invite subpackage rather than on com/codename1/analytics/ + // deliberately. Display and the Analytics facade are referenced by + // practically every application, so keying one package higher would put + // this dependency on all of them -- the DatabaseConfig failure recorded + // in AndroidGradleBuilder.usesClass, which the later deletion of the + // unused sources does not undo because the dependency is already in the + // gradle file. + // + // No androidMinimumSdk: checked against the real artifact rather than + // assumed. installreferrer 2.2, the newest release, declares + // minSdkVersion 8 in its own manifest, so it imposes no floor and + // adding one here would drop API 19 and 20 devices from an invite + // app's Play listing for nothing. The aar also declares the + // BIND_GET_INSTALL_REFERRER_SERVICE permission itself, so the manifest + // merger brings it in without a catalog entry. + // + // 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") + .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") + .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..3790e9348f3 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,60 @@ 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"); + // Checked against the real artifact: installreferrer 2.2 declares + // minSdkVersion 8 in its own manifest, so it imposes no floor. Adding + // one here would drop API 19 and 20 devices for nothing. + assertEquals(0, e.androidMinimumSdk(), + "the referrer library imposes no floor, so neither may this entry"); + 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(0, 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"); + } }