From 3ff3fed68f99ee34a1ac46dbeea8f59935c809ac Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:31:58 +0300 Subject: [PATCH 1/5] Analytics rewrite: generic provider SPI with GDPR consent and multiple backends Replace the deprecated Google Analytics v1 AnalyticsService with a generic provider SPI. Apps register one or more AnalyticsProvider implementations with the Analytics facade, which fans screen / event / user-property / crash calls out to all providers after a configurable (opt-in by default) consent gate. Providers: CodenameOneAnalyticsProvider (first-party, batched to the cloud), GoogleAnalyticsProvider (GA4), MatomoAnalyticsProvider (privacy-first, non Google), FirebaseAnalyticsProvider (Android + iOS native peers), and LoggingAnalyticsProvider (simulator / tests). The old AnalyticsService is retained, deprecated, and now delegates to the new API. Adds GDPR features (granular consent persisted across restarts, pseudonymous user-resettable client id), Firebase build-hint dependency injection in the Android (android.firebaseAnalytics) and iOS (ios.firebaseAnalytics) builders, a new developer-guide Analytics chapter, and 25 unit tests. The iOS native peer was verified with a full local arm64 xcodebuild (BUILD SUCCEEDED). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../analytics/AbstractAnalyticsProvider.java | 83 ++++ .../com/codename1/analytics/Analytics.java | 441 ++++++++++++++++++ .../analytics/AnalyticsCapability.java | 45 ++ .../codename1/analytics/AnalyticsConsent.java | 195 ++++++++ .../codename1/analytics/AnalyticsContext.java | 92 ++++ .../analytics/AnalyticsCrashReport.java | 149 ++++++ .../codename1/analytics/AnalyticsEvent.java | 165 +++++++ .../codename1/analytics/AnalyticsJson.java | 101 ++++ .../analytics/AnalyticsProvider.java | 112 +++++ .../codename1/analytics/AnalyticsService.java | 231 ++++----- .../CodenameOneAnalyticsProvider.java | 271 +++++++++++ .../com/codename1/analytics/ConsentMode.java | 39 ++ .../analytics/FirebaseAnalyticsProvider.java | 135 ++++++ .../analytics/GoogleAnalyticsProvider.java | 242 ++++++++++ .../LegacyAnalyticsProviderAdapter.java | 56 +++ .../analytics/LoggingAnalyticsProvider.java | 110 +++++ .../analytics/MatomoAnalyticsProvider.java | 176 +++++++ .../analytics/NativeFirebaseAnalytics.java | 75 +++ .../com/codename1/analytics/package-info.java | 29 +- .../NativeFirebaseAnalyticsImpl.java | 164 +++++++ ...e1_analytics_NativeFirebaseAnalyticsImpl.h | 39 ++ ...e1_analytics_NativeFirebaseAnalyticsImpl.m | 116 +++++ docs/developer-guide/Analytics.asciidoc | 178 +++++++ .../Miscellaneous-Features.asciidoc | 35 +- docs/developer-guide/developer-guide.asciidoc | 2 + docs/developer-guide/languagetool-accept.txt | 7 + .../vocabularies/CodenameOne/accept.txt | 7 + .../builders/AndroidGradleBuilder.java | 32 ++ .../com/codename1/builders/IPhoneBuilder.java | 14 + .../analytics/AnalyticsConsentTest.java | 67 +++ .../analytics/AnalyticsFacadeTest.java | 94 ++++ .../analytics/AnalyticsModelTest.java | 101 ++++ .../analytics/AnalyticsServiceTest.java | 2 + .../CodenameOneAnalyticsProviderTest.java | 61 +++ .../FirebaseAnalyticsProviderTest.java | 36 ++ .../GoogleAnalyticsProviderTest.java | 82 ++++ .../MatomoAnalyticsProviderTest.java | 50 ++ 37 files changed, 3647 insertions(+), 187 deletions(-) create mode 100644 CodenameOne/src/com/codename1/analytics/AbstractAnalyticsProvider.java create mode 100644 CodenameOne/src/com/codename1/analytics/Analytics.java create mode 100644 CodenameOne/src/com/codename1/analytics/AnalyticsCapability.java create mode 100644 CodenameOne/src/com/codename1/analytics/AnalyticsConsent.java create mode 100644 CodenameOne/src/com/codename1/analytics/AnalyticsContext.java create mode 100644 CodenameOne/src/com/codename1/analytics/AnalyticsCrashReport.java create mode 100644 CodenameOne/src/com/codename1/analytics/AnalyticsEvent.java create mode 100644 CodenameOne/src/com/codename1/analytics/AnalyticsJson.java create mode 100644 CodenameOne/src/com/codename1/analytics/AnalyticsProvider.java create mode 100644 CodenameOne/src/com/codename1/analytics/CodenameOneAnalyticsProvider.java create mode 100644 CodenameOne/src/com/codename1/analytics/ConsentMode.java create mode 100644 CodenameOne/src/com/codename1/analytics/FirebaseAnalyticsProvider.java create mode 100644 CodenameOne/src/com/codename1/analytics/GoogleAnalyticsProvider.java create mode 100644 CodenameOne/src/com/codename1/analytics/LegacyAnalyticsProviderAdapter.java create mode 100644 CodenameOne/src/com/codename1/analytics/LoggingAnalyticsProvider.java create mode 100644 CodenameOne/src/com/codename1/analytics/MatomoAnalyticsProvider.java create mode 100644 CodenameOne/src/com/codename1/analytics/NativeFirebaseAnalytics.java create mode 100644 Ports/Android/src/com/codename1/analytics/NativeFirebaseAnalyticsImpl.java create mode 100644 Ports/iOSPort/nativeSources/com_codename1_analytics_NativeFirebaseAnalyticsImpl.h create mode 100644 Ports/iOSPort/nativeSources/com_codename1_analytics_NativeFirebaseAnalyticsImpl.m create mode 100644 docs/developer-guide/Analytics.asciidoc create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsConsentTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsModelTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/CodenameOneAnalyticsProviderTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/FirebaseAnalyticsProviderTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/GoogleAnalyticsProviderTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/MatomoAnalyticsProviderTest.java diff --git a/CodenameOne/src/com/codename1/analytics/AbstractAnalyticsProvider.java b/CodenameOne/src/com/codename1/analytics/AbstractAnalyticsProvider.java new file mode 100644 index 0000000000..52827ca8eb --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/AbstractAnalyticsProvider.java @@ -0,0 +1,83 @@ +/* + * 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; + +/// Convenience base class for {@link AnalyticsProvider} implementations. Every +/// SPI method has an empty / no-op body so a concrete provider only overrides +/// the calls it actually supports. The {@link AnalyticsContext} handed to +/// {@link #init(AnalyticsContext)} is retained and exposed via +/// {@link #getContext()}; {@link #supports(AnalyticsCapability)} returns +/// {@code false} for every capability and should be overridden. +public abstract class AbstractAnalyticsProvider implements AnalyticsProvider { + private AnalyticsContext context; + + @Override + public abstract String getName(); + + @Override + public void init(AnalyticsContext context) { + this.context = context; + } + + /// The context supplied at {@link #init(AnalyticsContext)} time. + /// + /// #### Returns + /// + /// the context, or null if not yet initialised + protected AnalyticsContext getContext() { + return context; + } + + @Override + public void trackScreen(String name, String referrer) { + } + + @Override + public void trackEvent(AnalyticsEvent event) { + } + + @Override + public void setUserId(String id) { + } + + @Override + public void setUserProperty(String key, String value) { + } + + @Override + public void reportCrash(AnalyticsCrashReport report) { + } + + @Override + public void onConsentChanged(AnalyticsConsent consent) { + } + + @Override + public void flush() { + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return false; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java new file mode 100644 index 0000000000..e706648b22 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -0,0 +1,441 @@ +/* + * 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.Log; +import com.codename1.io.Preferences; +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Random; + +/// The application-facing entry point for analytics. {@code Analytics} holds the +/// set of registered {@link AnalyticsProvider providers}, the user's consent +/// state and the pseudonymous client id, and fans every reporting call out to +/// all providers -- but only once the relevant consent has been satisfied. +/// +/// ### Typical setup +/// +/// ```java +/// // register one or more providers (no reflection -- explicit instances) +/// Analytics.addProvider(new CodenameOneAnalyticsProvider()); +/// Analytics.addProvider(new GoogleAnalyticsProvider("G-XXXX", "api-secret")); +/// +/// // GDPR: nothing is sent until the user grants consent (opt-in is the default) +/// Analytics.setConsent(AnalyticsConsent.granted()); +/// +/// Analytics.screen("Home", null); +/// Analytics.event(AnalyticsEvent.create("purchase").param("value", 9.99).build()); +/// ``` +/// +/// ### Consent and privacy +/// +/// The {@link ConsentMode} governs behaviour before an explicit choice is +/// recorded. In the default {@link ConsentMode#OPT_IN} mode reporting calls are +/// silently dropped until {@link #setConsent(AnalyticsConsent)} grants the +/// matching category. The consent choice and the client id are persisted in +/// {@link Preferences} so they survive restarts. The client id is not derived +/// from any hardware identifier and can be cleared with {@link #resetClientId()} +/// to honour an erasure request. +public class Analytics { + private static final String PREF_CLIENT_ID = "cn1$analyticsClientId"; + private static final String PREF_CONSENT_SET = "cn1$analyticsConsentSet"; + private static final String PREF_CONSENT_ANALYTICS = "cn1$analyticsConsentAnalytics"; + private static final String PREF_CONSENT_CRASH = "cn1$analyticsConsentCrash"; + private static final String PREF_CONSENT_PERSONALIZATION = "cn1$analyticsConsentPersonalization"; + private static final String PREF_CONSENT_AD = "cn1$analyticsConsentAdStorage"; + + private static final Object LOCK = new Object(); + private static final List PROVIDERS = new ArrayList(); + private static ConsentMode consentMode = ConsentMode.OPT_IN; + private static AnalyticsConsent consent; + private static boolean consentLoaded; + private static String clientId; + + private Analytics() { + } + + /// Registers a provider and immediately supplies it with the current + /// {@link AnalyticsContext} and consent state. + /// + /// #### Parameters + /// + /// - `provider`: the provider to add, ignored if null + public static void addProvider(AnalyticsProvider provider) { + if (provider == null) { + return; + } + AnalyticsContext ctx = context(); + synchronized (LOCK) { + PROVIDERS.add(provider); + } + AnalyticsConsent c = currentConsent(); + try { + provider.init(ctx); + provider.onConsentChanged(c == null ? AnalyticsConsent.denied() : c); + } catch (Throwable t) { + Log.e(t); + } + } + + /// Removes a previously registered provider. + /// + /// #### Parameters + /// + /// - `provider`: the provider to remove + public static void removeProvider(AnalyticsProvider provider) { + synchronized (LOCK) { + PROVIDERS.remove(provider); + } + } + + /// Removes every registered provider. + public static void clearProviders() { + synchronized (LOCK) { + PROVIDERS.clear(); + } + } + + /// The currently registered providers. + /// + /// #### Returns + /// + /// an immutable copy of the provider list + public static List getProviders() { + synchronized (LOCK) { + return new ArrayList(PROVIDERS); + } + } + + /// Sets the consent mode that governs behaviour before an explicit consent + /// choice is recorded. Defaults to {@link ConsentMode#OPT_IN}. + /// + /// #### Parameters + /// + /// - `mode`: the consent mode, ignored if null + public static void setConsentMode(ConsentMode mode) { + if (mode == null) { + return; + } + synchronized (LOCK) { + consentMode = mode; + } + } + + /// The active consent mode. + /// + /// #### Returns + /// + /// the consent mode + public static ConsentMode getConsentMode() { + synchronized (LOCK) { + return consentMode; + } + } + + /// Records the user's consent choice, persists it and notifies every + /// provider. Passing null clears the stored choice (reverting to the + /// implicit behaviour of the current {@link ConsentMode}). + /// + /// #### Parameters + /// + /// - `newConsent`: the consent state, or null to clear + public static void setConsent(AnalyticsConsent newConsent) { + List snapshot; + synchronized (LOCK) { + consent = newConsent; + consentLoaded = true; + if (newConsent == null) { + Preferences.set(PREF_CONSENT_SET, false); + } else { + Preferences.set(PREF_CONSENT_SET, true); + Preferences.set(PREF_CONSENT_ANALYTICS, newConsent.isAnalytics()); + Preferences.set(PREF_CONSENT_CRASH, newConsent.isCrashReporting()); + Preferences.set(PREF_CONSENT_PERSONALIZATION, newConsent.isPersonalization()); + Preferences.set(PREF_CONSENT_AD, newConsent.isAdStorage()); + } + snapshot = new ArrayList(PROVIDERS); + } + AnalyticsConsent effective = newConsent == null ? AnalyticsConsent.denied() : newConsent; + for (AnalyticsProvider p : snapshot) { + try { + p.onConsentChanged(effective); + } catch (Throwable t) { + Log.e(t); + } + } + } + + /// The currently recorded consent, loading it from {@link Preferences} on + /// first access. Returns null if the user has not made an explicit choice. + /// + /// #### Returns + /// + /// the consent state or null + public static AnalyticsConsent getConsent() { + return currentConsent(); + } + + /// Records a screen / page view across all providers. No-op unless the + /// analytics consent category is satisfied. + /// + /// #### Parameters + /// + /// - `name`: the screen name + /// + /// - `referrer`: the previous screen, may be null + public static void screen(String name, String referrer) { + if (!analyticsAllowed()) { + return; + } + for (AnalyticsProvider p : snapshot()) { + try { + p.trackScreen(name, referrer); + } catch (Throwable t) { + Log.e(t); + } + } + } + + /// Records a named event across all providers. No-op unless the analytics + /// consent category is satisfied. + /// + /// #### Parameters + /// + /// - `event`: the event + public static void event(AnalyticsEvent event) { + if (event == null || !analyticsAllowed()) { + return; + } + for (AnalyticsProvider p : snapshot()) { + try { + p.trackEvent(event); + } catch (Throwable t) { + Log.e(t); + } + } + } + + /// Associates subsequent activity with a user id. No-op unless the + /// personalization consent category is satisfied. + /// + /// #### Parameters + /// + /// - `id`: the user id, or null to clear + public static void setUserId(String id) { + if (!personalizationAllowed()) { + return; + } + for (AnalyticsProvider p : snapshot()) { + try { + p.setUserId(id); + } catch (Throwable t) { + Log.e(t); + } + } + } + + /// Sets a user property / custom dimension. No-op unless the analytics + /// consent category is satisfied. + /// + /// #### Parameters + /// + /// - `key`: the property name + /// + /// - `value`: the property value + public static void setUserProperty(String key, String value) { + if (!analyticsAllowed()) { + return; + } + for (AnalyticsProvider p : snapshot()) { + try { + p.setUserProperty(key, value); + } catch (Throwable t) { + Log.e(t); + } + } + } + + /// Reports a crash / exception across all providers. No-op unless the crash + /// reporting consent category is satisfied. + /// + /// #### Parameters + /// + /// - `throwable`: the captured exception, may be null + /// + /// - `message`: a human readable description, may be null + /// + /// - `fatal`: whether the exception terminated the application + public static void crash(Throwable throwable, String message, boolean fatal) { + crash(AnalyticsCrashReport.create(throwable, message, fatal)); + } + + /// Reports a crash / exception across all providers. No-op unless the crash + /// reporting consent category is satisfied. + /// + /// #### Parameters + /// + /// - `report`: the crash report + public static void crash(AnalyticsCrashReport report) { + if (report == null || !crashAllowed()) { + return; + } + for (AnalyticsProvider p : snapshot()) { + try { + p.reportCrash(report); + } catch (Throwable t) { + Log.e(t); + } + } + } + + /// Flushes any buffered events in every provider. + public static void flush() { + for (AnalyticsProvider p : snapshot()) { + try { + p.flush(); + } catch (Throwable t) { + Log.e(t); + } + } + } + + /// The pseudonymous client id, generating and persisting one on first use. + /// Not derived from any hardware identifier. + /// + /// #### Returns + /// + /// the client id + public static String clientId() { + synchronized (LOCK) { + if (clientId == null) { + String stored = Preferences.get(PREF_CLIENT_ID, ""); + if (stored == null || stored.length() == 0) { + clientId = newClientId(); + Preferences.set(PREF_CLIENT_ID, clientId); + } else { + clientId = stored; + } + } + return clientId; + } + } + + /// Generates a fresh pseudonymous client id, persists it and re-initialises + /// every provider with the new identity. Use this to honour a "right to be + /// forgotten" / erasure request from the user. + /// + /// #### Returns + /// + /// the new client id + public static String resetClientId() { + List snapshot; + synchronized (LOCK) { + clientId = newClientId(); + Preferences.set(PREF_CLIENT_ID, clientId); + snapshot = new ArrayList(PROVIDERS); + } + AnalyticsContext ctx = context(); + for (AnalyticsProvider p : snapshot) { + try { + p.init(ctx); + } catch (Throwable t) { + Log.e(t); + } + } + return clientId; + } + + private static List snapshot() { + synchronized (LOCK) { + return new ArrayList(PROVIDERS); + } + } + + private static AnalyticsConsent currentConsent() { + synchronized (LOCK) { + if (!consentLoaded) { + consentLoaded = true; + if (Preferences.get(PREF_CONSENT_SET, false)) { + consent = new AnalyticsConsent( + Preferences.get(PREF_CONSENT_ANALYTICS, false), + Preferences.get(PREF_CONSENT_CRASH, false), + Preferences.get(PREF_CONSENT_PERSONALIZATION, false), + Preferences.get(PREF_CONSENT_AD, false)); + } + } + return consent; + } + } + + private static boolean analyticsAllowed() { + AnalyticsConsent c = currentConsent(); + if (getConsentMode() == ConsentMode.OPT_OUT) { + return c == null || c.isAnalytics(); + } + return c != null && c.isAnalytics(); + } + + private static boolean crashAllowed() { + AnalyticsConsent c = currentConsent(); + if (getConsentMode() == ConsentMode.OPT_OUT) { + return c == null || c.isCrashReporting(); + } + return c != null && c.isCrashReporting(); + } + + private static boolean personalizationAllowed() { + AnalyticsConsent c = currentConsent(); + if (getConsentMode() == ConsentMode.OPT_OUT) { + return c == null || c.isPersonalization(); + } + return c != null && c.isPersonalization(); + } + + private static AnalyticsContext context() { + Display d = Display.getInstance(); + String appName = ""; + String appVersion = "1.0"; + String platform = ""; + if (d != null) { + appName = d.getProperty("AppName", ""); + appVersion = d.getProperty("AppVersion", "1.0"); + platform = d.getPlatformName(); + } + Locale loc = Locale.getDefault(); + String locale = loc == null ? "" : loc.toString(); + return new AnalyticsContext(appName, appVersion, clientId(), locale, platform); + } + + private static String newClientId() { + Random r = new Random(); + char[] hex = "0123456789abcdef".toCharArray(); + StringBuilder b = new StringBuilder(32); + for (int i = 0; i < 32; i++) { + b.append(hex[r.nextInt(16)]); + } + return b.toString(); + } +} diff --git a/CodenameOne/src/com/codename1/analytics/AnalyticsCapability.java b/CodenameOne/src/com/codename1/analytics/AnalyticsCapability.java new file mode 100644 index 0000000000..539b02afdd --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/AnalyticsCapability.java @@ -0,0 +1,45 @@ +/* + * 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; + +/// Enumerates the analytics features a provider (or a server-side subscription +/// tier) may support. Code can introspect a provider via +/// {@link AnalyticsProvider#supports(AnalyticsCapability)} to decide whether a +/// given call will actually be honoured, and tooling can render the capability +/// matrix of the current account tier. +public enum AnalyticsCapability { + /// Reporting of screen / page views. + SCREEN_VIEWS, + /// Arbitrary named events with parameters. + EVENTS, + /// Per-user properties / custom dimensions. + USER_PROPERTIES, + /// Crash and exception reporting. + CRASH_REPORTING, + /// Live (near real-time) reporting. + REAL_TIME, + /// Conversion funnels. + FUNNELS, + /// Raw, per-event data export. + RAW_EXPORT +} diff --git a/CodenameOne/src/com/codename1/analytics/AnalyticsConsent.java b/CodenameOne/src/com/codename1/analytics/AnalyticsConsent.java new file mode 100644 index 0000000000..3c1fd6f8fe --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/AnalyticsConsent.java @@ -0,0 +1,195 @@ +/* + * 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; + +/// An immutable snapshot of the consent the user has granted, broken down by +/// category so that an application can honour granular choices (for example +/// allowing crash reporting while declining behavioural analytics). Instances +/// are created through {@link #granted()}, {@link #denied()} or {@link #builder()} +/// and handed to {@link Analytics#setConsent(AnalyticsConsent)}. +public final class AnalyticsConsent { + private final boolean analytics; + private final boolean crashReporting; + private final boolean personalization; + private final boolean adStorage; + + AnalyticsConsent(boolean analytics, boolean crashReporting, + boolean personalization, boolean adStorage) { + this.analytics = analytics; + this.crashReporting = crashReporting; + this.personalization = personalization; + this.adStorage = adStorage; + } + + /// Consent for screen views, events and user properties. + /// + /// #### Returns + /// + /// true if behavioural analytics collection is permitted + public boolean isAnalytics() { + return analytics; + } + + /// Consent for crash and exception reporting. + /// + /// #### Returns + /// + /// true if crash reporting is permitted + public boolean isCrashReporting() { + return crashReporting; + } + + /// Consent for personalization (e.g. user-level identification). + /// + /// #### Returns + /// + /// true if personalization is permitted + public boolean isPersonalization() { + return personalization; + } + + /// Consent for advertising / ad storage. + /// + /// #### Returns + /// + /// true if ad storage is permitted + public boolean isAdStorage() { + return adStorage; + } + + /// A consent object granting every category. Convenient when the user has + /// accepted a single all-encompassing consent prompt. + /// + /// #### Returns + /// + /// consent with all categories granted + public static AnalyticsConsent granted() { + return new AnalyticsConsent(true, true, true, true); + } + + /// A consent object denying every category. Equivalent to the implicit + /// state before consent is recorded in {@link ConsentMode#OPT_IN}. + /// + /// #### Returns + /// + /// consent with all categories denied + public static AnalyticsConsent denied() { + return new AnalyticsConsent(false, false, false, false); + } + + /// Creates a builder seeded with all categories denied. + /// + /// #### Returns + /// + /// a new builder + public static Builder builder() { + return new Builder(); + } + + /// Returns a builder seeded with this object's current values so a single + /// category can be toggled without rebuilding from scratch. + /// + /// #### Returns + /// + /// a builder pre-populated from this instance + public Builder asBuilder() { + return new Builder() + .analytics(analytics) + .crashReporting(crashReporting) + .personalization(personalization) + .adStorage(adStorage); + } + + /// Mutable builder for {@link AnalyticsConsent}. + public static final class Builder { + private boolean analytics; + private boolean crashReporting; + private boolean personalization; + private boolean adStorage; + + /// Sets the analytics category. + /// + /// #### Parameters + /// + /// - `value`: true to grant behavioural analytics + /// + /// #### Returns + /// + /// this builder + public Builder analytics(boolean value) { + this.analytics = value; + return this; + } + + /// Sets the crash reporting category. + /// + /// #### Parameters + /// + /// - `value`: true to grant crash reporting + /// + /// #### Returns + /// + /// this builder + public Builder crashReporting(boolean value) { + this.crashReporting = value; + return this; + } + + /// Sets the personalization category. + /// + /// #### Parameters + /// + /// - `value`: true to grant personalization + /// + /// #### Returns + /// + /// this builder + public Builder personalization(boolean value) { + this.personalization = value; + return this; + } + + /// Sets the ad storage category. + /// + /// #### Parameters + /// + /// - `value`: true to grant ad storage + /// + /// #### Returns + /// + /// this builder + public Builder adStorage(boolean value) { + this.adStorage = value; + return this; + } + + /// Builds the immutable consent object. + /// + /// #### Returns + /// + /// a new {@link AnalyticsConsent} + public AnalyticsConsent build() { + return new AnalyticsConsent(analytics, crashReporting, personalization, adStorage); + } + } +} diff --git a/CodenameOne/src/com/codename1/analytics/AnalyticsContext.java b/CodenameOne/src/com/codename1/analytics/AnalyticsContext.java new file mode 100644 index 0000000000..3a3efdef14 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/AnalyticsContext.java @@ -0,0 +1,92 @@ +/* + * 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; + +/// Immutable bundle of ambient information shared with every provider through +/// {@link AnalyticsProvider#init(AnalyticsContext)}. The {@link Analytics} +/// facade assembles it once from {@code Display} properties and the +/// pseudonymous client id so providers do not each have to re-derive it. +public final class AnalyticsContext { + private final String appName; + private final String appVersion; + private final String clientId; + private final String locale; + private final String platform; + + AnalyticsContext(String appName, String appVersion, String clientId, + String locale, String platform) { + this.appName = appName; + this.appVersion = appVersion; + this.clientId = clientId; + this.locale = locale; + this.platform = platform; + } + + /// The user-facing application name. + /// + /// #### Returns + /// + /// the app name + public String getAppName() { + return appName; + } + + /// The application version string. + /// + /// #### Returns + /// + /// the app version + public String getAppVersion() { + return appVersion; + } + + /// The pseudonymous, user-resettable client id. This is not derived from + /// any hardware identifier and may be cleared via + /// {@link Analytics#resetClientId()} to honour erasure requests. + /// + /// #### Returns + /// + /// the client id + public String getClientId() { + return clientId; + } + + /// The device locale (for example {@code en_US}). + /// + /// #### Returns + /// + /// the locale + public String getLocale() { + return locale; + } + + /// The platform name as reported by {@code Display.getPlatformName()} + /// (for example {@code and}, {@code ios}, {@code mac}). + /// + /// #### Returns + /// + /// the platform name + public String getPlatform() { + return platform; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/AnalyticsCrashReport.java b/CodenameOne/src/com/codename1/analytics/AnalyticsCrashReport.java new file mode 100644 index 0000000000..83297a580b --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/AnalyticsCrashReport.java @@ -0,0 +1,149 @@ +/* + * 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 java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/// A crash / exception report routed to providers that advertise +/// {@link AnalyticsCapability#CRASH_REPORTING}. This is the analytics +/// abstraction for exception telemetry; it is distinct from the dedicated +/// on-device crash-protection client in {@code com.codename1.crash}. +public final class AnalyticsCrashReport { + private final Throwable throwable; + private final String message; + private final boolean fatal; + private final Map customKeys; + + AnalyticsCrashReport(Throwable throwable, String message, boolean fatal, + Map customKeys) { + this.throwable = throwable; + this.message = message; + this.fatal = fatal; + this.customKeys = Collections.unmodifiableMap(customKeys); + } + + /// Creates a crash report. + /// + /// #### Parameters + /// + /// - `throwable`: the captured exception, may be null + /// + /// - `message`: a human readable description, may be null + /// + /// - `fatal`: true if the exception terminated the application + /// + /// #### Returns + /// + /// a new crash report + public static AnalyticsCrashReport create(Throwable throwable, String message, boolean fatal) { + return new AnalyticsCrashReport(throwable, message, fatal, new LinkedHashMap()); + } + + /// The captured throwable. + /// + /// #### Returns + /// + /// the throwable or null + public Throwable getThrowable() { + return throwable; + } + + /// The descriptive message. + /// + /// #### Returns + /// + /// the message or null + public String getMessage() { + return message; + } + + /// Whether the exception was fatal. + /// + /// #### Returns + /// + /// true if fatal + public boolean isFatal() { + return fatal; + } + + /// Optional custom key/value context attached to the report. + /// + /// #### Returns + /// + /// an unmodifiable map of custom keys + public Map getCustomKeys() { + return customKeys; + } + + /// Returns a builder for adding custom keys to a base report. + /// + /// #### Returns + /// + /// a builder seeded from this report + public Builder asBuilder() { + Builder b = new Builder(throwable, message, fatal); + b.customKeys.putAll(customKeys); + return b; + } + + /// Mutable builder for {@link AnalyticsCrashReport}. + public static final class Builder { + private final Throwable throwable; + private final String message; + private final boolean fatal; + private final Map customKeys = new LinkedHashMap(); + + Builder(Throwable throwable, String message, boolean fatal) { + this.throwable = throwable; + this.message = message; + this.fatal = fatal; + } + + /// Attaches a custom key/value pair to the report. + /// + /// #### Parameters + /// + /// - `key`: the key + /// + /// - `value`: the value + /// + /// #### Returns + /// + /// this builder + public Builder customKey(String key, String value) { + customKeys.put(key, value); + return this; + } + + /// Builds the immutable report. + /// + /// #### Returns + /// + /// a new {@link AnalyticsCrashReport} + public AnalyticsCrashReport build() { + return new AnalyticsCrashReport(throwable, message, fatal, customKeys); + } + } +} diff --git a/CodenameOne/src/com/codename1/analytics/AnalyticsEvent.java b/CodenameOne/src/com/codename1/analytics/AnalyticsEvent.java new file mode 100644 index 0000000000..174d6af449 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/AnalyticsEvent.java @@ -0,0 +1,165 @@ +/* + * 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 java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/// An immutable analytics event: a named action with an optional category and +/// an ordered set of parameters. Build one via {@link #create(String)}: +/// +/// ```java +/// Analytics.event(AnalyticsEvent.create("purchase") +/// .category("commerce") +/// .param("sku", "abc-123") +/// .param("value", 9.99) +/// .build()); +/// ``` +public final class AnalyticsEvent { + private final String name; + private final String category; + private final Map parameters; + private final long timestamp; + + AnalyticsEvent(String name, String category, Map parameters, long timestamp) { + this.name = name; + this.category = category; + this.parameters = Collections.unmodifiableMap(parameters); + this.timestamp = timestamp; + } + + /// Starts building an event with the given name. + /// + /// #### Parameters + /// + /// - `name`: the event name, must not be null + /// + /// #### Returns + /// + /// a new builder + public static Builder create(String name) { + return new Builder(name); + } + + /// The event name. + /// + /// #### Returns + /// + /// the name + public String getName() { + return name; + } + + /// The optional event category. + /// + /// #### Returns + /// + /// the category or null + public String getCategory() { + return category; + } + + /// The event parameters as an unmodifiable, insertion-ordered map. + /// + /// #### Returns + /// + /// the parameters + public Map getParameters() { + return parameters; + } + + /// The client timestamp in milliseconds since the epoch. + /// + /// #### Returns + /// + /// the timestamp + public long getTimestamp() { + return timestamp; + } + + /// Mutable builder for {@link AnalyticsEvent}. + public static final class Builder { + private final String name; + private String category; + private final Map parameters = new LinkedHashMap(); + private long timestamp = System.currentTimeMillis(); + + Builder(String name) { + this.name = name; + } + + /// Sets the event category. + /// + /// #### Parameters + /// + /// - `category`: the category + /// + /// #### Returns + /// + /// this builder + public Builder category(String category) { + this.category = category; + return this; + } + + /// Adds or replaces a parameter. + /// + /// #### Parameters + /// + /// - `key`: the parameter name + /// + /// - `value`: the parameter value (String, Number or Boolean) + /// + /// #### Returns + /// + /// this builder + public Builder param(String key, Object value) { + parameters.put(key, value); + return this; + } + + /// Overrides the default client timestamp. + /// + /// #### Parameters + /// + /// - `timestamp`: milliseconds since the epoch + /// + /// #### Returns + /// + /// this builder + public Builder timestamp(long timestamp) { + this.timestamp = timestamp; + return this; + } + + /// Builds the immutable event. + /// + /// #### Returns + /// + /// a new {@link AnalyticsEvent} + public AnalyticsEvent build() { + return new AnalyticsEvent(name, category, parameters, timestamp); + } + } +} diff --git a/CodenameOne/src/com/codename1/analytics/AnalyticsJson.java b/CodenameOne/src/com/codename1/analytics/AnalyticsJson.java new file mode 100644 index 0000000000..b56be1b3f4 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/AnalyticsJson.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; + +/// Minimal hand-rolled JSON writing helpers shared by the analytics providers. +/// Mirrors the approach used by {@code com.codename1.crash} so the device does +/// not pull in a JSON parser dependency, and produces RFC 8259 compliant +/// strings. +final class AnalyticsJson { + private AnalyticsJson() { + } + + static void appendString(StringBuilder b, String key, String value, boolean first) { + if (!first) { + b.append(','); + } + b.append('"').append(key).append("\":"); + if (value == null) { + b.append("null"); + return; + } + b.append('"'); + escape(b, value); + b.append('"'); + } + + static void appendLong(StringBuilder b, String key, long value, boolean first) { + if (!first) { + b.append(','); + } + b.append('"').append(key).append("\":").append(value); + } + + static void appendBoolean(StringBuilder b, String key, boolean value, boolean first) { + if (!first) { + b.append(','); + } + b.append('"').append(key).append("\":").append(value); + } + + static void appendValue(StringBuilder b, Object value) { + if (value == null) { + b.append("null"); + return; + } + if (value instanceof Number || value instanceof Boolean) { + b.append(value.toString()); + return; + } + b.append('"'); + escape(b, value.toString()); + b.append('"'); + } + + static void escape(StringBuilder b, String value) { + int len = value.length(); + for (int i = 0; i < len; i++) { + char c = value.charAt(i); + switch (c) { + case '"': b.append("\\\""); break; + case '\\': b.append("\\\\"); break; + case '\b': b.append("\\b"); break; + case '\f': b.append("\\f"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: + if (c < 0x20) { + b.append("\\u"); + String hex = Integer.toHexString(c); + for (int p = hex.length(); p < 4; p++) { + b.append('0'); + } + b.append(hex); + } else { + b.append(c); + } + } + } + } +} diff --git a/CodenameOne/src/com/codename1/analytics/AnalyticsProvider.java b/CodenameOne/src/com/codename1/analytics/AnalyticsProvider.java new file mode 100644 index 0000000000..c60365e877 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/AnalyticsProvider.java @@ -0,0 +1,112 @@ +/* + * 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; + +/// The analytics service provider interface (SPI). Implement this to plug an +/// analytics backend into the {@link Analytics} facade. Several providers can +/// be registered at once; the facade fans every call out to all of them after +/// the relevant consent has been satisfied. +/// +/// Providers are registered by explicit instantiation +/// (`Analytics.addProvider(new MyProvider())`) -- no reflection or service +/// lookup is used, which keeps the mechanism safe under the obfuscation applied +/// to shipped builds. Most implementations should extend +/// {@link AbstractAnalyticsProvider} and override only the calls they support. +public interface AnalyticsProvider { + /// A short, stable, human readable name for this provider (used in logs and + /// diagnostics). + /// + /// #### Returns + /// + /// the provider name + String getName(); + + /// Called once when the provider is registered with the facade, supplying + /// ambient application context. + /// + /// #### Parameters + /// + /// - `context`: the analytics context + void init(AnalyticsContext context); + + /// Records a screen / page view. + /// + /// #### Parameters + /// + /// - `name`: the screen name + /// + /// - `referrer`: the previous screen name, may be null + void trackScreen(String name, String referrer); + + /// Records a named event. + /// + /// #### Parameters + /// + /// - `event`: the event + void trackEvent(AnalyticsEvent event); + + /// Associates subsequent activity with a user identifier. + /// + /// #### Parameters + /// + /// - `id`: the user id, or null to clear + void setUserId(String id); + + /// Sets a user-level property / custom dimension. + /// + /// #### Parameters + /// + /// - `key`: the property name + /// + /// - `value`: the property value + void setUserProperty(String key, String value); + + /// Reports a crash or handled exception. + /// + /// #### Parameters + /// + /// - `report`: the crash report + void reportCrash(AnalyticsCrashReport report); + + /// Notifies the provider that the user's consent has changed so it can + /// enable, disable or reconfigure collection accordingly. + /// + /// #### Parameters + /// + /// - `consent`: the new consent state + void onConsentChanged(AnalyticsConsent consent); + + /// Flushes any buffered events to the backend. + void flush(); + + /// Indicates whether the provider supports a given capability. + /// + /// #### Parameters + /// + /// - `capability`: the capability to query + /// + /// #### Returns + /// + /// true if supported + boolean supports(AnalyticsCapability capability); +} diff --git a/CodenameOne/src/com/codename1/analytics/AnalyticsService.java b/CodenameOne/src/com/codename1/analytics/AnalyticsService.java index 4bd8d3c734..a0ef5fb248 100644 --- a/CodenameOne/src/com/codename1/analytics/AnalyticsService.java +++ b/CodenameOne/src/com/codename1/analytics/AnalyticsService.java @@ -23,23 +23,29 @@ package com.codename1.analytics; import com.codename1.io.ConnectionRequest; -import com.codename1.io.Log; -import com.codename1.io.NetworkManager; -import com.codename1.ui.Display; -import com.codename1.ui.events.ActionEvent; -import com.codename1.ui.events.ActionListener; -/// The analytics service allows an application to report its usage, it is seamlessly -/// invoked by GUI builder applications if analytics is enabled for your application but can -/// work just as well for handcoded apps! +/// The legacy analytics entry point, retained for backward compatibility. New +/// code should use the {@link Analytics} facade together with one or more +/// {@link AnalyticsProvider} implementations, which adds a generic provider SPI, +/// GDPR/CCPA consent handling and modern backends (GA4, Matomo, Firebase and +/// the first-party Codename One service). /// -/// To enable analytics just use the `java.lang.String)` -/// method of the analytics service. For most typical usage you should also invoke the -/// `#setAppsMode(boolean)` method with `true`. If you are -/// not using the GUI builder invoke the visit method whenever you would like to log a -/// page view event. +/// This class now delegates to {@link Analytics}: {@link #init(String, String)} +/// registers a {@link GoogleAnalyticsProvider} (Google Analytics 4, replacing +/// the retired Measurement Protocol v1 that this class used to target) and +/// {@link #visit(String, String)} / {@link #sendCrashReport(Throwable, String, +/// boolean)} route through the facade. +/// +/// To preserve the historical "always on" behaviour of this deprecated API -- +/// which predates the consent model -- {@link #init(String, String)} and +/// {@link #init(AnalyticsService)} switch the facade to +/// {@link ConsentMode#OPT_OUT}. Applications that need opt-in / GDPR behaviour +/// should migrate to the {@link Analytics} API and call +/// {@link Analytics#setConsent(AnalyticsConsent)}. /// /// @author Shai Almog +/// @deprecated use {@link Analytics} and an {@link AnalyticsProvider} +@Deprecated public class AnalyticsService { private static final Object INSTANCE_LOCK = new Object(); private static AnalyticsService instance; @@ -49,13 +55,15 @@ public class AnalyticsService { private static int readTimeout; private String agent; private String domain; - private ConnectionRequest lastRequest; /// Indicates whether analytics server failures should brodcast an error event /// /// #### Returns /// /// the failSilently + /// + /// @deprecated use {@link Analytics} + @Deprecated public static boolean isFailSilently() { return failSilently; } @@ -65,6 +73,9 @@ public static boolean isFailSilently() { /// #### Parameters /// /// - `aFailSilently`: the failSilently to set + /// + /// @deprecated use {@link Analytics} + @Deprecated public static void setFailSilently(boolean aFailSilently) { failSilently = aFailSilently; } @@ -74,42 +85,47 @@ public static void setFailSilently(boolean aFailSilently) { /// #### Returns /// /// the appsMode + /// + /// @deprecated use {@link Analytics} + @Deprecated public static boolean isAppsMode() { return appsMode; } /// Apps mode allows improved analytics using the newer google analytics API designed for apps. - /// Most developers should invoke this method with `true`. + /// This setting is retained for source compatibility but no longer affects behaviour; the + /// modern GA4 protocol is always used. /// /// #### Parameters /// /// - `aAppsMode`: the appsMode to set + /// + /// @deprecated use {@link Analytics} + @Deprecated public static void setAppsMode(boolean aAppsMode) { appsMode = aAppsMode; } - /// Sets timeout for HTTP requests to Google Analytics service. + /// Retained for source compatibility; no longer affects behaviour. /// /// #### Parameters /// /// - `ms`: Milliseconds timeout. /// - /// #### Since - /// - /// 7.0 + /// @deprecated use {@link Analytics} + @Deprecated public static void setTimeout(int ms) { timeout = ms; } - /// Sets read timeout for HTTP requests to Google Analytics services. + /// Retained for source compatibility; no longer affects behaviour. /// /// #### Parameters /// /// - `ms`: Milliseconds read timeout. /// - /// #### Since - /// - /// 7.0 + /// @deprecated use {@link Analytics} + @Deprecated public static void setReadTimeout(int ms) { readTimeout = ms; } @@ -119,18 +135,25 @@ public static void setReadTimeout(int ms) { /// #### Returns /// /// true if analytics is enabled + /// + /// @deprecated use {@link Analytics} + @Deprecated public static boolean isEnabled() { return instance != null && instance.isAnalyticsEnabled(); } - /// Initializes google analytics for this application + /// Initializes analytics for this application using the modern Google + /// Analytics 4 protocol. The `agent` is used as the GA4 measurement id. /// /// #### Parameters /// - /// - `agent`: the google analytics tracking agent + /// - `agent`: the google analytics tracking agent / measurement id + /// + /// - `domain`: a domain to represent your application, commonly your package name as a URL + /// (e.g. com.mycompany.myapp should become: myapp.mycompany.com) /// - /// - `domain`: @param domain a domain to represent your application, commonly you should use your package name as a URL (e.g. - /// com.mycompany.myapp should become: myapp.mycompany.com) + /// @deprecated use {@link Analytics} with a {@link GoogleAnalyticsProvider} + @Deprecated public static void init(String agent, String domain) { synchronized (INSTANCE_LOCK) { if (instance == null) { @@ -138,33 +161,46 @@ public static void init(String agent, String domain) { } instance.agent = agent; instance.domain = domain; + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.clearProviders(); + Analytics.addProvider(new GoogleAnalyticsProvider(agent, "")); } } - /// Allows installing an analytics service other than the default + /// Allows installing an analytics service other than the default. The custom + /// implementation's {@code visitPage} hook is invoked for page views. /// /// #### Parameters /// /// - `i`: the analytics service implementation. + /// + /// @deprecated use {@link Analytics} and a custom {@link AnalyticsProvider} + @Deprecated public static void init(AnalyticsService i) { synchronized (INSTANCE_LOCK) { instance = i; + Analytics.setConsentMode(ConsentMode.OPT_OUT); } } - /// Sends an asynchronous notice to the server regarding a page in the application being viewed, notice that - /// you don't need to append the URL prefix to the page string. + /// Sends an asynchronous notice to the server regarding a page in the application being viewed, + /// notice that you don't need to append the URL prefix to the page string. /// /// #### Parameters /// /// - `page`: the page viewed /// /// - `referer`: the source page + /// + /// @deprecated use {@link Analytics#screen(String, String)} + @Deprecated public static void visit(String page, String referer) { - instance.visitPage(page, referer); + if (instance != null) { + instance.visitPage(page, referer); + } } - /// In apps mode we can send information about an exception to the analytics server + /// Reports information about an exception to the analytics server. /// /// #### Parameters /// @@ -173,37 +209,11 @@ public static void visit(String page, String referer) { /// - `message`: up to 150 character message, /// /// - `fatal`: is the exception fatal + /// + /// @deprecated use {@link Analytics#crash(Throwable, String, boolean)} + @Deprecated public static void sendCrashReport(Throwable t, String message, boolean fatal) { - // https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide#exception - ConnectionRequest req = getGaRequest(); - req.addArgument("t", "exception"); - System.out.println(message); - req.addArgument("exd", message.substring(0, Math.min(message.length(), 150) - 1)); - if (fatal) { - req.addArgument("exf", "1"); - } else { - req.addArgument("exf", "0"); - } - - NetworkManager.getInstance().addToQueue(req); - } - - private static ConnectionRequest getGaRequest() { - ConnectionRequest req = new ConnectionRequest(); - req.setUrl("https://www.google-analytics.com/collect"); - req.setPost(true); - req.setFailSilently(true); - req.addArgument("v", "1"); - req.addArgument("tid", instance.agent); - if (timeout > 0) { - req.setTimeout(timeout); - } - if (readTimeout > 0) { - req.setReadTimeout(readTimeout); - } - long uniqueId = Log.getUniqueDeviceId(); - req.addArgument("cid", String.valueOf(uniqueId)); - return req; + Analytics.crash(t, message, fatal); } /// Indicates if the analytics is enabled, subclasses must override this method to process their information @@ -212,15 +222,12 @@ private static ConnectionRequest getGaRequest() { /// /// true if analytics is enabled protected boolean isAnalyticsEnabled() { - return agent != null; + return agent != null || !Analytics.getProviders().isEmpty(); } - /// Decorates the ConnectionRequest to be sent to the server before the request is sent. - /// This can be overridden to add additional request parameters to the service, and hence provide - /// additional analytics data. - /// - /// If using Google Analytics, the current you can see the available POST parameters that - /// the server accepts [here](https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters). + /// Retained for source compatibility with subclasses written against the + /// previous Google Analytics v1 implementation. It is no longer invoked by + /// the default page-view path, which now delegates to {@link Analytics}. /// /// #### Parameters /// @@ -230,14 +237,13 @@ protected boolean isAnalyticsEnabled() { /// /// - `request`: The ConnectionRequest /// - /// #### Since - /// - /// 7.0 + /// @deprecated decorate the request inside a custom {@link AnalyticsProvider} + @Deprecated protected void decorateVisitPageRequest(String page, String referer, ConnectionRequest request) { - } - /// Subclasses should override this method to track page visits + /// Subclasses may override this method to track page visits. The default + /// implementation routes the visit to the {@link Analytics} facade. /// /// #### Parameters /// @@ -245,81 +251,6 @@ protected void decorateVisitPageRequest(String page, String referer, ConnectionR /// /// - `referer`: the page from which the user came protected void visitPage(String page, String referer) { - if (lastRequest != null) { - final String fPage = page; - final String fReferer = referer; - ActionListener onComplete = new ActionListener() { - @Override - public void actionPerformed(ActionEvent evt) { - visitPage(fPage, fReferer); - } - }; - lastRequest.addResponseListener(onComplete); - lastRequest.addResponseCodeListener(onComplete); - lastRequest.addExceptionListener(onComplete); - return; - } - if (appsMode) { - // https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide#apptracking - final ConnectionRequest req = getGaRequest(); - req.addArgument("t", "appview"); - req.addArgument("an", Display.getInstance().getProperty("AppName", "Codename One App")); - String version = Display.getInstance().getProperty("AppVersion", "1.0"); - req.addArgument("av", version); - req.addArgument("cd", page); - ActionListener onComplete = new ActionListener() { - @Override - public void actionPerformed(ActionEvent evt) { - if (req == lastRequest) { //NOPMD CompareObjectsWithEquals - lastRequest = null; - } - } - }; - req.addResponseListener(onComplete); - req.addResponseCodeListener(onComplete); - req.addExceptionListener(onComplete); - lastRequest = req; - decorateVisitPageRequest(page, referer, req); - NetworkManager.getInstance().addToQueue(req); - } else { - String url = Display.getInstance().getProperty("cloudServerURL", "https://codename-one.appspot.com/") + "anal"; - final ConnectionRequest r = new ConnectionRequest(); - r.setUrl(url); - r.setPost(false); - r.setFailSilently(failSilently); - r.addArgument("guid", "ON"); - r.addArgument("utmac", instance.agent); - r.addArgument("utmn", Integer.toString((int) (System.currentTimeMillis() % 0x7fffffff))); - if (page == null || page.length() == 0) { - page = "-"; - } - r.addArgument("utmp", page); - if (referer == null || referer.length() == 0) { - referer = "-"; - } - r.addArgument("utmr", referer); - r.addArgument("d", instance.domain); - r.setPriority(ConnectionRequest.PRIORITY_LOW); - if (timeout > 0) { - r.setTimeout(timeout); - } - if (readTimeout > 0) { - r.setReadTimeout(readTimeout); - } - ActionListener onComplete = new ActionListener() { - @Override - public void actionPerformed(ActionEvent evt) { - if (r == lastRequest) { //NOPMD CompareObjectsWithEquals - lastRequest = null; - } - } - }; - r.addResponseListener(onComplete); - r.addResponseCodeListener(onComplete); - r.addExceptionListener(onComplete); - lastRequest = r; - decorateVisitPageRequest(page, referer, r); - NetworkManager.getInstance().addToQueue(r); - } + Analytics.screen(page, referer); } } diff --git a/CodenameOne/src/com/codename1/analytics/CodenameOneAnalyticsProvider.java b/CodenameOne/src/com/codename1/analytics/CodenameOneAnalyticsProvider.java new file mode 100644 index 0000000000..8c1c9d8971 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/CodenameOneAnalyticsProvider.java @@ -0,0 +1,271 @@ +/* + * 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.ConnectionRequest; +import com.codename1.io.NetworkManager; +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/// The Codename One first-party analytics provider. It batches events and +/// posts them as JSON to the Codename One cloud +/// (`/api/v2/analytics/events`), where they are stored and aggregated for the +/// reports shown in the developer console. The capabilities you actually get +/// (screen views, custom events, retention window, raw export) are gated +/// server-side by your subscription tier. +/// +/// App identity is read from the build-injected `Display` properties +/// (`build_key`, `package_name`, `AppName`, `AppVersion`, `OSVer`) -- the same +/// mechanism the on-device crash client uses -- so no API key needs to be +/// embedded in the app. Events are buffered in memory and flushed when the +/// batch fills up or when {@link #flush()} is called. +/// +/// ```java +/// Analytics.addProvider(new CodenameOneAnalyticsProvider()); +/// Analytics.setConsent(AnalyticsConsent.granted()); +/// ``` +public class CodenameOneAnalyticsProvider extends AbstractAnalyticsProvider { + private static final String DEFAULT_BASE_URL = "https://cloud.codenameone.com"; + private static final String PATH = "/api/v2/analytics/events"; + private static final String SCREEN_VIEW = "screen_view"; + private static final int DEFAULT_BATCH_SIZE = 20; + + private final Object lock = new Object(); + private final List buffer = new ArrayList(); + private int batchSize = DEFAULT_BATCH_SIZE; + private String endpoint; + + /// Overrides the ingest endpoint URL. By default the provider posts to the + /// Codename One cloud (honouring the `cloudServerURL` display property). + /// + /// #### Parameters + /// + /// - `url`: the full ingest URL + public void setEndpoint(String url) { + this.endpoint = url; + } + + /// Sets the number of events buffered before an automatic flush. + /// + /// #### Parameters + /// + /// - `size`: the batch size, values below 1 are clamped to 1 + public void setBatchSize(int size) { + this.batchSize = size < 1 ? 1 : size; + } + + @Override + public String getName() { + return "codenameone"; + } + + @Override + public void trackScreen(String name, String referrer) { + PendingEvent e = new PendingEvent(); + e.name = SCREEN_VIEW; + e.screen = name; + if (referrer != null && referrer.length() > 0) { + e.params = new java.util.LinkedHashMap(); + e.params.put("referrer", referrer); + } + enqueue(e); + } + + @Override + public void trackEvent(AnalyticsEvent event) { + PendingEvent e = new PendingEvent(); + e.name = event.getName(); + e.category = event.getCategory(); + e.clientTs = event.getTimestamp(); + e.params = event.getParameters(); + enqueue(e); + } + + @Override + public void setUserProperty(String key, String value) { + PendingEvent e = new PendingEvent(); + e.name = "user_property"; + e.params = new java.util.LinkedHashMap(); + e.params.put("key", key); + e.params.put("value", value); + enqueue(e); + } + + @Override + public void reportCrash(AnalyticsCrashReport report) { + String description = report.getMessage(); + if ((description == null || description.length() == 0) && report.getThrowable() != null) { + description = report.getThrowable().getClass().getName(); + } + PendingEvent e = new PendingEvent(); + e.name = "app_exception"; + e.params = new java.util.LinkedHashMap(); + e.params.put("description", description == null ? "" : description); + e.params.put("fatal", Boolean.valueOf(report.isFatal())); + enqueue(e); + } + + @Override + public void flush() { + List pending; + synchronized (lock) { + if (buffer.isEmpty()) { + return; + } + pending = new ArrayList(buffer); + buffer.clear(); + } + post(buildBatch(pending)); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return capability == AnalyticsCapability.SCREEN_VIEWS + || capability == AnalyticsCapability.EVENTS + || capability == AnalyticsCapability.USER_PROPERTIES + || capability == AnalyticsCapability.CRASH_REPORTING + || capability == AnalyticsCapability.REAL_TIME + || capability == AnalyticsCapability.FUNNELS + || capability == AnalyticsCapability.RAW_EXPORT; + } + + private void enqueue(PendingEvent e) { + if (e.clientTs <= 0) { + e.clientTs = System.currentTimeMillis(); + } + boolean full; + synchronized (lock) { + buffer.add(e); + full = buffer.size() >= batchSize; + } + if (full) { + flush(); + } + } + + private String buildBatch(List events) { + AnalyticsContext ctx = getContext(); + Display d = Display.getInstance(); + String clientId = ctx == null || ctx.getClientId() == null ? "" : ctx.getClientId(); + String appName = ctx == null ? "" : ctx.getAppName(); + String appVersion = ctx == null ? "" : ctx.getAppVersion(); + String platform = ctx == null ? "" : ctx.getPlatform(); + String locale = ctx == null ? "" : ctx.getLocale(); + String buildKey = d == null ? "" : d.getProperty("build_key", ""); + String packageName = d == null ? "" : d.getProperty("package_name", ""); + String osVersion = d == null ? "" : d.getProperty("OSVer", ""); + if (locale == null || locale.length() == 0) { + Locale loc = Locale.getDefault(); + locale = loc == null ? "" : loc.toString(); + } + + StringBuilder b = new StringBuilder(256); + b.append('{'); + AnalyticsJson.appendString(b, "clientId", clientId, true); + AnalyticsJson.appendString(b, "buildKey", buildKey, false); + AnalyticsJson.appendString(b, "packageName", packageName, false); + AnalyticsJson.appendString(b, "appName", appName, false); + AnalyticsJson.appendString(b, "appVersion", appVersion, false); + AnalyticsJson.appendString(b, "platform", platform, false); + AnalyticsJson.appendString(b, "osVersion", osVersion, false); + AnalyticsJson.appendString(b, "locale", locale, false); + // Reaching here means the Analytics facade already satisfied the + // analytics consent gate; the flag tells the server consent was + // granted (consent travels with the data). + AnalyticsJson.appendBoolean(b, "consentAnalytics", true, false); + b.append(",\"events\":["); + for (int i = 0; i < events.size(); i++) { + if (i > 0) { + b.append(','); + } + appendEvent(b, events.get(i)); + } + b.append("]}"); + return b.toString(); + } + + private static void appendEvent(StringBuilder b, PendingEvent e) { + b.append('{'); + AnalyticsJson.appendString(b, "name", e.name, true); + AnalyticsJson.appendString(b, "category", e.category, false); + AnalyticsJson.appendString(b, "screen", e.screen, false); + AnalyticsJson.appendLong(b, "clientTs", e.clientTs, false); + b.append(",\"params\":"); + if (e.params == null || e.params.isEmpty()) { + b.append("{}"); + } else { + b.append('{'); + boolean first = true; + for (Map.Entry p : e.params.entrySet()) { + if (!first) { + b.append(','); + } + b.append('"'); + AnalyticsJson.escape(b, p.getKey()); + b.append("\":"); + AnalyticsJson.appendValue(b, p.getValue()); + first = false; + } + b.append('}'); + } + b.append('}'); + } + + private void post(String json) { + ConnectionRequest req = new ConnectionRequest(); + req.setUrl(resolveEndpoint()); + req.setPost(true); + req.setHttpMethod("POST"); + req.setContentType("application/json"); + req.setRequestBody(json); + req.setFailSilently(true); + NetworkManager.getInstance().addToQueue(req); + } + + private String resolveEndpoint() { + if (endpoint != null && endpoint.length() > 0) { + return endpoint; + } + Display d = Display.getInstance(); + String base = d == null ? DEFAULT_BASE_URL : d.getProperty("cloudServerURL", DEFAULT_BASE_URL); + if (base == null || base.length() == 0) { + base = DEFAULT_BASE_URL; + } + if (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base + PATH; + } + + private static final class PendingEvent { + private String name; + private String category; + private String screen; + private long clientTs; + private Map params; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/ConsentMode.java b/CodenameOne/src/com/codename1/analytics/ConsentMode.java new file mode 100644 index 0000000000..b002c47d4d --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/ConsentMode.java @@ -0,0 +1,39 @@ +/* + * 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; + +/// Controls the default behaviour of {@link Analytics} before the application +/// has recorded an explicit consent choice from the user. This is the central +/// switch for GDPR / CCPA compliance. +public enum ConsentMode { + /// Nothing is collected or transmitted until the application records a + /// positive consent via {@link Analytics#setConsent(AnalyticsConsent)}. + /// This is the privacy-safe default and the recommended setting for apps + /// distributed in regulated jurisdictions. + OPT_IN, + + /// Collection is active by default; the user (or the app on the user's + /// behalf) may withdraw consent at any time. Easier adoption but places + /// the compliance burden on the integrator. + OPT_OUT +} diff --git a/CodenameOne/src/com/codename1/analytics/FirebaseAnalyticsProvider.java b/CodenameOne/src/com/codename1/analytics/FirebaseAnalyticsProvider.java new file mode 100644 index 0000000000..4393b652ca --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/FirebaseAnalyticsProvider.java @@ -0,0 +1,135 @@ +/* + * 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.system.NativeLookup; + +import java.util.Map; + +/// A provider that forwards analytics to the native Firebase Analytics +/// SDK through the {@link NativeFirebaseAnalytics} native interface. On +/// Android and iOS, with Firebase configured in the build, events flow +/// to the Firebase console. Where no native peer exists (the simulator, +/// or a build without Firebase set up) the provider degrades silently to +/// a no-op, so it is always safe to register. +/// +/// ```java +/// Analytics.addProvider(new FirebaseAnalyticsProvider()); +/// ``` +/// +/// Firebase requires the usual platform configuration: +/// `google-services.json` (Android) / `GoogleService-Info.plist` (iOS) +/// plus the Firebase dependencies in the generated native project. +public class FirebaseAnalyticsProvider extends AbstractAnalyticsProvider { + private NativeFirebaseAnalytics peer; + private boolean available; + + @Override + public String getName() { + return "firebase"; + } + + @Override + public void init(AnalyticsContext context) { + super.init(context); + try { + peer = NativeLookup.create(NativeFirebaseAnalytics.class); + available = peer != null && peer.isSupported(); + } catch (Throwable t) { + available = false; + } + } + + @Override + public void trackScreen(String name, String referrer) { + if (available) { + peer.logScreen(name); + } + } + + @Override + public void trackEvent(AnalyticsEvent event) { + if (available) { + peer.logEvent(event.getName(), paramsJson(event.getParameters())); + } + } + + @Override + public void setUserId(String id) { + if (available) { + peer.setUserId(id); + } + } + + @Override + public void setUserProperty(String key, String value) { + if (available) { + peer.setUserProperty(key, value); + } + } + + @Override + public void reportCrash(AnalyticsCrashReport report) { + if (!available) { + return; + } + String description = report.getMessage(); + if ((description == null || description.length() == 0) && report.getThrowable() != null) { + description = report.getThrowable().getClass().getName(); + } + StringBuilder b = new StringBuilder(); + b.append('{'); + AnalyticsJson.appendString(b, "description", description == null ? "" : description, true); + AnalyticsJson.appendBoolean(b, "fatal", report.isFatal(), false); + b.append('}'); + peer.logEvent("app_exception", b.toString()); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return capability == AnalyticsCapability.SCREEN_VIEWS + || capability == AnalyticsCapability.EVENTS + || capability == AnalyticsCapability.USER_PROPERTIES + || capability == AnalyticsCapability.CRASH_REPORTING; + } + + private static String paramsJson(Map params) { + StringBuilder b = new StringBuilder(); + b.append('{'); + if (params != null) { + boolean first = true; + for (Map.Entry e : params.entrySet()) { + if (!first) { + b.append(','); + } + b.append('"'); + AnalyticsJson.escape(b, e.getKey()); + b.append("\":"); + AnalyticsJson.appendValue(b, e.getValue()); + first = false; + } + } + b.append('}'); + return b.toString(); + } +} diff --git a/CodenameOne/src/com/codename1/analytics/GoogleAnalyticsProvider.java b/CodenameOne/src/com/codename1/analytics/GoogleAnalyticsProvider.java new file mode 100644 index 0000000000..843c15e700 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/GoogleAnalyticsProvider.java @@ -0,0 +1,242 @@ +/* + * 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.ConnectionRequest; +import com.codename1.io.NetworkManager; +import com.codename1.io.Util; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// A provider for Google Analytics 4 using the Measurement Protocol (v2). This +/// replaces the retired Universal Analytics / Measurement Protocol v1 endpoint +/// that the legacy {@code AnalyticsService} targeted. +/// +/// Create one with a GA4 measurement id (`G-XXXXXXXX`) and a Measurement +/// Protocol API secret generated in the GA4 admin console: +/// +/// ```java +/// Analytics.addProvider(new GoogleAnalyticsProvider("G-XXXXXXXX", "MY_API_SECRET")); +/// ``` +/// +/// Screen views are sent as the GA4 `screen_view` event, custom events use the +/// (sanitised) event name, and crashes are reported as `app_exception`. IP +/// addresses are anonymised by GA4 by default. Consent is enforced upstream by +/// {@link Analytics}, so this provider sends whatever it is handed. +public class GoogleAnalyticsProvider extends AbstractAnalyticsProvider { + private static final String DEFAULT_ENDPOINT = "https://www.google-analytics.com/mp/collect"; + + private final String measurementId; + private final String apiSecret; + private String endpoint = DEFAULT_ENDPOINT; + private String userId; + private final Map userProperties = new LinkedHashMap(); + + /// Creates a GA4 provider. + /// + /// #### Parameters + /// + /// - `measurementId`: the GA4 measurement id, e.g. `G-XXXXXXXX` + /// + /// - `apiSecret`: a Measurement Protocol API secret + public GoogleAnalyticsProvider(String measurementId, String apiSecret) { + this.measurementId = measurementId; + this.apiSecret = apiSecret; + } + + /// Overrides the collection endpoint. Useful for routing through a + /// first-party proxy (or a test server). + /// + /// #### Parameters + /// + /// - `url`: the collection endpoint URL + public void setEndpoint(String url) { + this.endpoint = url; + } + + @Override + public String getName() { + return "google-analytics"; + } + + @Override + public void setUserId(String id) { + this.userId = id; + } + + @Override + public void setUserProperty(String key, String value) { + if (key == null) { + return; + } + if (value == null) { + userProperties.remove(key); + } else { + userProperties.put(sanitizeName(key), value); + } + } + + @Override + public void trackScreen(String name, String referrer) { + StringBuilder params = new StringBuilder(); + params.append('{'); + AnalyticsJson.appendString(params, "screen_name", name, true); + if (referrer != null && referrer.length() > 0) { + AnalyticsJson.appendString(params, "referrer", referrer, false); + } + AnalyticsJson.appendLong(params, "engagement_time_msec", 1, false); + params.append('}'); + send("screen_view", params.toString()); + } + + @Override + public void trackEvent(AnalyticsEvent event) { + StringBuilder params = new StringBuilder(); + params.append('{'); + boolean first = true; + if (event.getCategory() != null) { + AnalyticsJson.appendString(params, "category", event.getCategory(), true); + first = false; + } + for (Map.Entry e : event.getParameters().entrySet()) { + if (!first) { + params.append(','); + } + params.append('"'); + AnalyticsJson.escape(params, sanitizeName(e.getKey())); + params.append("\":"); + AnalyticsJson.appendValue(params, e.getValue()); + first = false; + } + params.append('}'); + send(sanitizeName(event.getName()), params.toString()); + } + + @Override + public void reportCrash(AnalyticsCrashReport report) { + String description = report.getMessage(); + if ((description == null || description.length() == 0) && report.getThrowable() != null) { + description = report.getThrowable().getClass().getName(); + } + if (description == null) { + description = ""; + } + if (description.length() > 100) { + description = description.substring(0, 100); + } + StringBuilder params = new StringBuilder(); + params.append('{'); + AnalyticsJson.appendString(params, "description", description, true); + AnalyticsJson.appendBoolean(params, "fatal", report.isFatal(), false); + params.append('}'); + send("app_exception", params.toString()); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return capability == AnalyticsCapability.SCREEN_VIEWS + || capability == AnalyticsCapability.EVENTS + || capability == AnalyticsCapability.USER_PROPERTIES + || capability == AnalyticsCapability.CRASH_REPORTING + || capability == AnalyticsCapability.REAL_TIME; + } + + private void send(String eventName, String paramsJson) { + StringBuilder b = new StringBuilder(); + b.append('{'); + AnalyticsJson.appendString(b, "client_id", clientId(), true); + if (userId != null) { + AnalyticsJson.appendString(b, "user_id", userId, false); + } + if (!userProperties.isEmpty()) { + b.append(",\"user_properties\":{"); + boolean firstProp = true; + for (Map.Entry e : userProperties.entrySet()) { + if (!firstProp) { + b.append(','); + } + b.append('"'); + AnalyticsJson.escape(b, e.getKey()); + b.append("\":{"); + AnalyticsJson.appendString(b, "value", e.getValue(), true); + b.append('}'); + firstProp = false; + } + b.append('}'); + } + b.append(",\"events\":[{"); + AnalyticsJson.appendString(b, "name", eventName, true); + b.append(",\"params\":").append(paramsJson); + b.append("}]}"); + post(b.toString()); + } + + private void post(String json) { + ConnectionRequest req = new ConnectionRequest(); + req.setUrl(endpoint + "?measurement_id=" + Util.encodeUrl(measurementId) + + "&api_secret=" + Util.encodeUrl(apiSecret)); + req.setPost(true); + req.setHttpMethod("POST"); + req.setContentType("application/json"); + req.setRequestBody(json); + req.setFailSilently(true); + NetworkManager.getInstance().addToQueue(req); + } + + private String clientId() { + AnalyticsContext ctx = getContext(); + if (ctx != null && ctx.getClientId() != null) { + return ctx.getClientId(); + } + return ""; + } + + /// GA4 event and parameter names must be alphanumeric or underscore and + /// begin with a letter. This replaces any other character with an + /// underscore and prefixes a leading digit. + private static String sanitizeName(String name) { + if (name == null || name.length() == 0) { + return "event"; + } + StringBuilder b = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + boolean letter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + boolean digit = c >= '0' && c <= '9'; + if (letter || digit || c == '_') { + b.append(c); + } else { + b.append('_'); + } + } + char first = b.charAt(0); + if (!((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_')) { + b.insert(0, '_'); + } + if (b.length() > 40) { + return b.substring(0, 40); + } + return b.toString(); + } +} diff --git a/CodenameOne/src/com/codename1/analytics/LegacyAnalyticsProviderAdapter.java b/CodenameOne/src/com/codename1/analytics/LegacyAnalyticsProviderAdapter.java new file mode 100644 index 0000000000..a03b6973a6 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/LegacyAnalyticsProviderAdapter.java @@ -0,0 +1,56 @@ +/* + * 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; + +/// Bridges a legacy {@link AnalyticsService} subclass into the new +/// {@link AnalyticsProvider} SPI so that custom analytics implementations +/// written against the deprecated API can participate in the +/// {@link Analytics} facade alongside modern providers. Screen views are routed +/// to the subclass's {@code visitPage} hook. +public class LegacyAnalyticsProviderAdapter extends AbstractAnalyticsProvider { + private final AnalyticsService delegate; + + /// Wraps a legacy analytics service. + /// + /// #### Parameters + /// + /// - `delegate`: the legacy analytics service implementation + public LegacyAnalyticsProviderAdapter(AnalyticsService delegate) { + this.delegate = delegate; + } + + @Override + public String getName() { + return "legacy"; + } + + @Override + public void trackScreen(String name, String referrer) { + delegate.visitPage(name, referrer); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return capability == AnalyticsCapability.SCREEN_VIEWS; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/LoggingAnalyticsProvider.java b/CodenameOne/src/com/codename1/analytics/LoggingAnalyticsProvider.java new file mode 100644 index 0000000000..245911168b --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/LoggingAnalyticsProvider.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.analytics; + +import com.codename1.io.Log; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// A provider that simply logs every call (and records them in memory) instead +/// of sending data anywhere. It is the recommended default in the simulator and +/// is used as the backing provider in unit tests, where {@link #getLog()} lets a +/// test assert exactly which calls were made. +public class LoggingAnalyticsProvider extends AbstractAnalyticsProvider { + private final List log = new ArrayList(); + + @Override + public String getName() { + return "logging"; + } + + /// The in-memory record of calls received by this provider, in order. Each + /// entry is a short description such as {@code "screen:Home"} or + /// {@code "event:purchase"}. + /// + /// #### Returns + /// + /// the recorded call log + public List getLog() { + return log; + } + + /// Clears the recorded call log. + public void clearLog() { + log.clear(); + } + + @Override + public void trackScreen(String name, String referrer) { + record("screen:" + name + (referrer == null ? "" : " <- " + referrer)); + } + + @Override + public void trackEvent(AnalyticsEvent event) { + StringBuilder b = new StringBuilder("event:"); + b.append(event.getName()); + Map params = event.getParameters(); + if (!params.isEmpty()) { + b.append(' ').append(params.toString()); + } + record(b.toString()); + } + + @Override + public void setUserId(String id) { + record("userId:" + id); + } + + @Override + public void setUserProperty(String key, String value) { + record("userProperty:" + key + "=" + value); + } + + @Override + public void reportCrash(AnalyticsCrashReport report) { + record("crash:" + report.getMessage() + " fatal=" + report.isFatal()); + } + + @Override + public void onConsentChanged(AnalyticsConsent consent) { + record("consent:analytics=" + consent.isAnalytics() + + " crash=" + consent.isCrashReporting()); + } + + @Override + public void flush() { + record("flush"); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return true; + } + + private void record(String entry) { + log.add(entry); + Log.p("[analytics] " + entry); + } +} diff --git a/CodenameOne/src/com/codename1/analytics/MatomoAnalyticsProvider.java b/CodenameOne/src/com/codename1/analytics/MatomoAnalyticsProvider.java new file mode 100644 index 0000000000..0da330be6b --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/MatomoAnalyticsProvider.java @@ -0,0 +1,176 @@ +/* + * 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.ConnectionRequest; +import com.codename1.io.NetworkManager; + +import java.util.Map; + +/// A privacy-first, non-Google provider targeting Matomo (formerly Piwik) +/// through its HTTP Tracking API. Matomo can be self-hosted and offers IP +/// anonymisation and consent-aware tracking, which makes it a natural fit for +/// GDPR-sensitive deployments. The same SPI shape applies to comparable +/// privacy-focused backends (PostHog, Plausible), so swapping is trivial. +/// +/// ```java +/// Analytics.addProvider(new MatomoAnalyticsProvider("https://matomo.example.com", 1)); +/// ``` +/// +/// Screen views map to Matomo actions; events use Matomo's `e_c`/`e_a`/`e_n` +/// event parameters; crashes are reported as an event in the `crash` category. +/// The pseudonymous client id is truncated to Matomo's 16-character visitor id. +public class MatomoAnalyticsProvider extends AbstractAnalyticsProvider { + private final String trackerUrl; + private final String idSite; + private String userId; + + /// Creates a Matomo provider. + /// + /// #### Parameters + /// + /// - `matomoBaseUrl`: the Matomo base URL or full `matomo.php` tracker URL + /// + /// - `idSite`: the Matomo site id + public MatomoAnalyticsProvider(String matomoBaseUrl, int idSite) { + this.trackerUrl = normalize(matomoBaseUrl); + this.idSite = Integer.toString(idSite); + } + + @Override + public String getName() { + return "matomo"; + } + + @Override + public void setUserId(String id) { + this.userId = id; + } + + @Override + public void trackScreen(String name, String referrer) { + ConnectionRequest r = base(); + String safeName = name == null ? "" : name; + r.addArgument("action_name", safeName); + r.addArgument("url", pseudoUrl(safeName)); + if (referrer != null && referrer.length() > 0) { + r.addArgument("urlref", pseudoUrl(referrer)); + } + NetworkManager.getInstance().addToQueue(r); + } + + @Override + public void trackEvent(AnalyticsEvent event) { + ConnectionRequest r = base(); + r.addArgument("url", pseudoUrl(event.getName())); + r.addArgument("e_c", event.getCategory() == null ? "event" : event.getCategory()); + r.addArgument("e_a", event.getName()); + Map params = event.getParameters(); + Object label = params.get("label"); + if (label != null) { + r.addArgument("e_n", label.toString()); + } + Object value = params.get("value"); + if (value instanceof Number) { + r.addArgument("e_v", value.toString()); + } + NetworkManager.getInstance().addToQueue(r); + } + + @Override + public void reportCrash(AnalyticsCrashReport report) { + String description = report.getMessage(); + if ((description == null || description.length() == 0) && report.getThrowable() != null) { + description = report.getThrowable().getClass().getName(); + } + if (description == null) { + description = ""; + } + ConnectionRequest r = base(); + r.addArgument("url", pseudoUrl("crash")); + r.addArgument("e_c", "crash"); + r.addArgument("e_a", report.isFatal() ? "fatal" : "handled"); + r.addArgument("e_n", description); + NetworkManager.getInstance().addToQueue(r); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return capability == AnalyticsCapability.SCREEN_VIEWS + || capability == AnalyticsCapability.EVENTS + || capability == AnalyticsCapability.CRASH_REPORTING + || capability == AnalyticsCapability.REAL_TIME; + } + + private ConnectionRequest base() { + ConnectionRequest r = new ConnectionRequest(); + r.setUrl(trackerUrl); + r.setPost(false); + r.setFailSilently(true); + r.addArgument("idsite", idSite); + r.addArgument("rec", "1"); + r.addArgument("apiv", "1"); + r.addArgument("send_image", "0"); + r.addArgument("_id", visitorId()); + r.addArgument("rand", Long.toString(System.currentTimeMillis())); + if (userId != null) { + r.addArgument("uid", userId); + } + return r; + } + + private String visitorId() { + AnalyticsContext ctx = getContext(); + String c = ctx == null || ctx.getClientId() == null ? "" : ctx.getClientId(); + if (c.length() >= 16) { + return c.substring(0, 16); + } + StringBuilder b = new StringBuilder(c); + while (b.length() < 16) { + b.append('0'); + } + return b.toString(); + } + + private String pseudoUrl(String path) { + String host = "app"; + AnalyticsContext ctx = getContext(); + if (ctx != null && ctx.getAppName() != null && ctx.getAppName().length() > 0) { + host = ctx.getAppName(); + } + return "https://" + host + "/" + (path == null ? "" : path); + } + + private static String normalize(String base) { + if (base == null) { + return ""; + } + if (base.endsWith("/matomo.php") || base.endsWith("/piwik.php")) { + return base; + } + if (base.endsWith("/")) { + return base + "matomo.php"; + } + return base + "/matomo.php"; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/NativeFirebaseAnalytics.java b/CodenameOne/src/com/codename1/analytics/NativeFirebaseAnalytics.java new file mode 100644 index 0000000000..517387efc2 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/NativeFirebaseAnalytics.java @@ -0,0 +1,75 @@ +/* + * 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.system.NativeInterface; + +/// Native bridge for {@link FirebaseAnalyticsProvider}. Each platform +/// supplies an implementation that forwards to the native Firebase +/// Analytics SDK: +/// +/// - **Android**: `FirebaseAnalytics.getInstance(context).logEvent(...)` +/// / `setUserId` / `setUserProperty` (requires `google-services.json` +/// and the Firebase Gradle plugin in the build). +/// - **iOS**: `FIRAnalytics logEventWithName:parameters:` / +/// `setUserID:` / `setUserProperty:forName:` (requires +/// `GoogleService-Info.plist` and the Firebase pods). +/// +/// When no native peer is present (for example in the simulator, or in a +/// build without Firebase configured), {@code NativeLookup.create} +/// returns {@code null} / {@link #isSupported()} returns false and the +/// provider degrades to a no-op. Parameters are passed as a JSON object +/// string so the native side can map them to the SDK's bundle / NSDictionary. +public interface NativeFirebaseAnalytics extends NativeInterface { + /// Logs a named event with a JSON object of parameters. + /// + /// #### Parameters + /// + /// - `name`: the event name + /// + /// - `paramsJson`: a JSON object of parameters, may be empty + public void logEvent(String name, String paramsJson); + + /// Logs a screen view. + /// + /// #### Parameters + /// + /// - `screenName`: the screen name + public void logScreen(String screenName); + + /// Sets the Firebase user id. + /// + /// #### Parameters + /// + /// - `id`: the user id, or null to clear + public void setUserId(String id); + + /// Sets a Firebase user property. + /// + /// #### Parameters + /// + /// - `key`: the property name + /// + /// - `value`: the property value + public void setUserProperty(String key, String value); +} diff --git a/CodenameOne/src/com/codename1/analytics/package-info.java b/CodenameOne/src/com/codename1/analytics/package-info.java index ee3f5f45b7..5f8d37ab5c 100644 --- a/CodenameOne/src/com/codename1/analytics/package-info.java +++ b/CodenameOne/src/com/codename1/analytics/package-info.java @@ -1,6 +1,27 @@ -/// The analytics API allows tracking your mobile application usage in the field to give you real-time -/// data on how your application is used. This API currently delegates to the Google analytics service. +/// The analytics API tracks how your application is used in the field through a +/// generic provider SPI. Register one or more {@link com.codename1.analytics.AnalyticsProvider} +/// implementations with the {@link com.codename1.analytics.Analytics} facade and +/// report screen views, events, user properties and crashes; the facade fans +/// each call out to every provider once the relevant consent has been granted. /// -/// Notice that analytics is automatically added GUI applications created by the old GUI builder, you only need -/// to enable Analytics specifically by invoking the init method and the pages will be logged automatically. +/// Built-in providers include the first-party +/// {@link com.codename1.analytics.CodenameOneAnalyticsProvider} (reporting into +/// the Codename One cloud, with capabilities gated by your subscription tier), +/// {@link com.codename1.analytics.GoogleAnalyticsProvider} (Google Analytics 4), +/// {@link com.codename1.analytics.MatomoAnalyticsProvider} (privacy-first, self +/// hostable), {@link com.codename1.analytics.FirebaseAnalyticsProvider} and the +/// {@link com.codename1.analytics.LoggingAnalyticsProvider} used in the +/// simulator and tests. +/// +/// Consent is configurable and defaults to opt-in +/// ({@link com.codename1.analytics.ConsentMode#OPT_IN}): nothing is collected or +/// transmitted until the application records a choice via +/// {@link com.codename1.analytics.Analytics#setConsent(com.codename1.analytics.AnalyticsConsent)}, +/// helping you comply with GDPR / CCPA. The pseudonymous client id is not +/// derived from any hardware identifier and can be cleared with +/// {@link com.codename1.analytics.Analytics#resetClientId()} to honour erasure +/// requests. +/// +/// The previous {@link com.codename1.analytics.AnalyticsService} entry point is +/// retained, deprecated, and now delegates to this API. package com.codename1.analytics; diff --git a/Ports/Android/src/com/codename1/analytics/NativeFirebaseAnalyticsImpl.java b/Ports/Android/src/com/codename1/analytics/NativeFirebaseAnalyticsImpl.java new file mode 100644 index 0000000000..9fa50360b2 --- /dev/null +++ b/Ports/Android/src/com/codename1/analytics/NativeFirebaseAnalyticsImpl.java @@ -0,0 +1,164 @@ +/* + * 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 android.content.Context; +import android.os.Bundle; +import com.codename1.impl.android.AndroidNativeUtil; +import java.lang.reflect.Method; +import java.util.Iterator; +import org.json.JSONObject; + +/** + * Android implementation of {@link NativeFirebaseAnalytics}, delegating to the + * Firebase Analytics SDK. + * + *

The SDK is invoked reflectively -- the same approach the Android port + * already uses for Firebase Cloud Messaging (see + * {@code AndroidImplementation}'s {@code FirebaseMessaging} lookup). Reflection + * keeps the Android port compilable without a {@code firebase-analytics} + * dependency on its own classpath; the Gradle dependency is added to the + * generated app by {@code AndroidGradleBuilder} when the + * {@code android.firebaseAnalytics=true} build hint is set. If the SDK is not + * present at runtime every method is a safe no-op and {@link #isSupported()} + * returns false, so the {@link FirebaseAnalyticsProvider} degrades cleanly. + */ +public class NativeFirebaseAnalyticsImpl implements NativeFirebaseAnalytics { + private Object firebaseAnalytics; + private boolean resolved; + + private Object instance() { + if (!resolved) { + resolved = true; + try { + Context c = AndroidNativeUtil.getContext(); + if (c != null) { + Class cls = Class.forName("com.google.firebase.analytics.FirebaseAnalytics"); + Method getInstance = cls.getMethod("getInstance", Context.class); + firebaseAnalytics = getInstance.invoke(null, c); + } + } catch (Throwable t) { + firebaseAnalytics = null; + } + } + return firebaseAnalytics; + } + + @Override + public boolean isSupported() { + return instance() != null; + } + + @Override + public void logEvent(String name, String paramsJson) { + logEventBundle(sanitize(name), toBundle(paramsJson)); + } + + @Override + public void logScreen(String screenName) { + Bundle b = new Bundle(); + b.putString("screen_name", screenName); + logEventBundle("screen_view", b); + } + + @Override + public void setUserId(String id) { + Object fa = instance(); + if (fa == null) { + return; + } + try { + fa.getClass().getMethod("setUserId", String.class).invoke(fa, id); + } catch (Throwable t) { + // no-op + } + } + + @Override + public void setUserProperty(String key, String value) { + Object fa = instance(); + if (fa == null) { + return; + } + try { + fa.getClass().getMethod("setUserProperty", String.class, String.class).invoke(fa, key, value); + } catch (Throwable t) { + // no-op + } + } + + private void logEventBundle(String name, Bundle params) { + Object fa = instance(); + if (fa == null) { + return; + } + try { + Method m = fa.getClass().getMethod("logEvent", String.class, Bundle.class); + m.invoke(fa, name, params); + } catch (Throwable t) { + // no-op + } + } + + private static Bundle toBundle(String json) { + Bundle b = new Bundle(); + if (json == null || json.length() == 0) { + return b; + } + try { + JSONObject o = new JSONObject(json); + Iterator keys = o.keys(); + while (keys.hasNext()) { + String k = keys.next(); + Object v = o.get(k); + if (v instanceof Number) { + b.putDouble(k, ((Number) v).doubleValue()); + } else if (v instanceof Boolean) { + b.putString(k, v.toString()); + } else { + b.putString(k, String.valueOf(v)); + } + } + } catch (Throwable t) { + // ignore malformed params; send the event without them + } + return b; + } + + /** Firebase event / param names must be alphanumeric or underscore. */ + private static String sanitize(String name) { + if (name == null || name.length() == 0) { + return "event"; + } + StringBuilder sb = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + sb.append(c); + } else { + sb.append('_'); + } + } + return sb.toString(); + } +} diff --git a/Ports/iOSPort/nativeSources/com_codename1_analytics_NativeFirebaseAnalyticsImpl.h b/Ports/iOSPort/nativeSources/com_codename1_analytics_NativeFirebaseAnalyticsImpl.h new file mode 100644 index 0000000000..2c8f45c1bb --- /dev/null +++ b/Ports/iOSPort/nativeSources/com_codename1_analytics_NativeFirebaseAnalyticsImpl.h @@ -0,0 +1,39 @@ +/* + * 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. + */ + +// Objective-C peer for the com.codename1.analytics.NativeFirebaseAnalytics +// native interface. The Codename One iOS builder generates a bridge that +// resolves this class by name (cn1_createNativeInterfacePeer) and invokes +// these selectors; the selector keywords (logEvent:param1: etc.) match the +// builder's keyword convention (first arg is the method name, subsequent args +// are "paramN"). + +#import + +@interface com_codename1_analytics_NativeFirebaseAnalyticsImpl : NSObject +- (BOOL)isSupported; +- (void)logEvent:(NSString*)param0 param1:(NSString*)param1; +- (void)logScreen:(NSString*)param0; +- (void)setUserId:(NSString*)param0; +- (void)setUserProperty:(NSString*)param0 param1:(NSString*)param1; +@end diff --git a/Ports/iOSPort/nativeSources/com_codename1_analytics_NativeFirebaseAnalyticsImpl.m b/Ports/iOSPort/nativeSources/com_codename1_analytics_NativeFirebaseAnalyticsImpl.m new file mode 100644 index 0000000000..95673b7d54 --- /dev/null +++ b/Ports/iOSPort/nativeSources/com_codename1_analytics_NativeFirebaseAnalyticsImpl.m @@ -0,0 +1,116 @@ +/* + * 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. + */ + +// Objective-C implementation of the NativeFirebaseAnalytics peer. FIRAnalytics +// is invoked through the Objective-C runtime (NSClassFromString / +// performSelector) rather than a compile-time #import so this source compiles +// even in apps that do not include the Firebase pod; there isSupported returns +// NO and every call is a no-op, so FirebaseAnalyticsProvider degrades cleanly. +// The Firebase/Analytics pod is added by IPhoneBuilder when the +// ios.firebaseAnalytics=true build hint is set. + +#import "com_codename1_analytics_NativeFirebaseAnalyticsImpl.h" + +// The optional FIRAnalytics selectors are invoked dynamically (the Firebase +// SDK headers are intentionally not imported -- see the file header), so clang +// cannot see their declarations. Silence the resulting -Wundeclared-selector. +#pragma clang diagnostic ignored "-Wundeclared-selector" + +@implementation com_codename1_analytics_NativeFirebaseAnalyticsImpl + +static Class firAnalyticsClass(void) { + return NSClassFromString(@"FIRAnalytics"); +} + +- (BOOL)isSupported { + return firAnalyticsClass() != nil; +} + +- (void)logEvent:(NSString*)param0 param1:(NSString*)param1 { + Class fir = firAnalyticsClass(); + if (fir == nil) { + return; + } + NSDictionary* params = nil; + if (param1 != nil && param1.length > 0) { + NSData* data = [param1 dataUsingEncoding:NSUTF8StringEncoding]; + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if ([parsed isKindOfClass:[NSDictionary class]]) { + params = (NSDictionary*) parsed; + } + } + SEL sel = @selector(logEventWithName:parameters:); + if ([fir respondsToSelector:sel]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [fir performSelector:sel withObject:param0 withObject:params]; +#pragma clang diagnostic pop + } +} + +- (void)logScreen:(NSString*)param0 { + Class fir = firAnalyticsClass(); + if (fir == nil) { + return; + } + NSDictionary* params = param0 != nil ? @{ @"screen_name": param0 } : @{}; + SEL sel = @selector(logEventWithName:parameters:); + if ([fir respondsToSelector:sel]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [fir performSelector:sel withObject:@"screen_view" withObject:params]; +#pragma clang diagnostic pop + } +} + +- (void)setUserId:(NSString*)param0 { + Class fir = firAnalyticsClass(); + if (fir == nil) { + return; + } + SEL sel = @selector(setUserID:); + if ([fir respondsToSelector:sel]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [fir performSelector:sel withObject:param0]; +#pragma clang diagnostic pop + } +} + +- (void)setUserProperty:(NSString*)param0 param1:(NSString*)param1 { + Class fir = firAnalyticsClass(); + if (fir == nil) { + return; + } + // FIRAnalytics signature is setUserPropertyString:(value) forName:(name); + // param0 is the key (name), param1 is the value. + SEL sel = @selector(setUserPropertyString:forName:); + if ([fir respondsToSelector:sel]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [fir performSelector:sel withObject:param1 withObject:param0]; +#pragma clang diagnostic pop + } +} + +@end diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc new file mode 100644 index 0000000000..4644cd16fb --- /dev/null +++ b/docs/developer-guide/Analytics.asciidoc @@ -0,0 +1,178 @@ +== Analytics + +The analytics API records how your application is used in the field. It's built around a small provider SPI: you register one or more providers with the `Analytics` entry point, then report screen views, events, user properties and crashes. The `Analytics` class fans every call out to all registered providers, but only after the user has granted the matching consent. + +Codename One ships several providers: a first-party service that reports into the Codename One cloud (with capabilities gated by your subscription tier), Google Analytics 4, Matomo (a privacy-first, self-hostable option), Firebase, and a logging provider for the simulator and tests. You can also write your own provider for any other backend. + +NOTE: The older `AnalyticsService` class still works but is deprecated. It now delegates to the API described here. See <> at the end of this chapter. + +=== Registering providers + +Register providers once, early in your app's lifecycle. Providers are plain objects -- there is no reflection or service lookup, which keeps the mechanism safe under the obfuscation applied to release builds. + +[source,java] +---- +// Codename One's first-party service (reports into the cloud console) +Analytics.addProvider(new CodenameOneAnalyticsProvider()); + +// or a third-party backend, side by side +Analytics.addProvider(new GoogleAnalyticsProvider("G-XXXXXXXX", "API_SECRET")); +---- + +Once at least one provider is registered, report usage from anywhere in your app: + +[source,java] +---- +Analytics.screen("Home", null); + +Analytics.event(AnalyticsEvent.create("purchase") + .category("commerce") + .param("sku", "abc-123") + .param("value", 9.99) + .build()); + +Analytics.setUserProperty("plan", "pro"); +Analytics.crash(throwable, "checkout failed", false); +---- + +=== Consent and privacy + +The API is designed to help you comply with privacy regulations such as GDPR and CCPA. A `ConsentMode` controls what happens before the user makes an explicit choice. The default is `OPT_IN`: nothing is collected or transmitted until the application records consent. + +[source,java] +---- +// Nothing is sent until this is called (opt-in is the default). +Analytics.setConsent(AnalyticsConsent.granted()); +---- + +Consent is broken down by category, so you can honour granular choices -- for example allowing crash reporting while declining behavioural analytics: + +[source,java] +---- +Analytics.setConsent(AnalyticsConsent.builder() + .analytics(true) + .crashReporting(true) + .personalization(false) + .build()); +---- + +The consent choice is persisted, so it survives restarts. Screen views and events require the `analytics` category, crash reports require `crashReporting`, and `setUserId` requires `personalization`. Calls made without the matching consent are dropped. + +If your app isn't subject to opt-in regulations you can switch the default: + +[source,java] +---- +Analytics.setConsentMode(ConsentMode.OPT_OUT); +---- + +==== Client identity and erasure + +Each device is identified by a pseudonymous client id. It's generated on first use and stored locally -- it isn't derived from any hardware identifier. To honour an erasure request ("right to be forgotten"), reset it: + +[source,java] +---- +Analytics.resetClientId(); +---- + +For the first-party service this can be paired with a server-side deletion of the previous id's data. + +=== Built-in providers + +==== Codename One first-party service + +`CodenameOneAnalyticsProvider` batches events and posts them to the Codename One cloud, which aggregates them into the reports shown in the developer console. App identity is read from the build-injected properties, so no API key is embedded in your app. + +[source,java] +---- +Analytics.addProvider(new CodenameOneAnalyticsProvider()); +---- + +The capabilities you actually get -- screen views, custom events, user properties, retention window, raw export -- are gated server-side by your subscription tier. Higher tiers retain data longer and unlock richer reporting. + +==== Google Analytics 4 + +`GoogleAnalyticsProvider` uses the GA4 Measurement Protocol. Create it with a measurement id (`G-XXXXXXXX`) and a Measurement Protocol API secret from the GA4 admin console: + +[source,java] +---- +Analytics.addProvider(new GoogleAnalyticsProvider("G-XXXXXXXX", "API_SECRET")); +---- + +Screen views are sent as the GA4 `screen_view` event and crashes as `app_exception`. + +==== Matomo + +`MatomoAnalyticsProvider` targets Matomo (formerly Piwik) through its HTTP tracking API. Matomo can be self-hosted and supports IP anonymisation, which makes it a good fit for privacy-sensitive deployments: + +[source,java] +---- +Analytics.addProvider(new MatomoAnalyticsProvider("https://matomo.example.com", 1)); +---- + +==== Firebase + +`FirebaseAnalyticsProvider` forwards to the native Firebase Analytics SDK on Android and iOS. Register it like any other provider: + +[source,java] +---- +Analytics.addProvider(new FirebaseAnalyticsProvider()); +---- + +Firebase needs platform configuration and the SDK in the native build. Add the Firebase configuration file to your project (`google-services.json` under `native/android`, `GoogleService-Info.plist` under `native/ios`) and enable the SDK with build hints: + +[source] +---- +codename1.arg.android.firebaseAnalytics=true +codename1.arg.ios.firebaseAnalytics=true +---- + +These hints tell the build to add the Firebase Analytics Gradle dependency (Android) and the `Firebase/Analytics` pod (iOS). Where Firebase isn't configured -- including the simulator -- the provider degrades to a no-op, so it's always safe to register. + +==== Logging provider + +`LoggingAnalyticsProvider` logs every call instead of sending it anywhere. It's the recommended default in the simulator and is used as the backing provider in tests. + +[source,java] +---- +Analytics.addProvider(new LoggingAnalyticsProvider()); +---- + +=== Writing your own provider + +Implement `AnalyticsProvider`, or extend `AbstractAnalyticsProvider` and override only the calls you support. The `Analytics` class handles consent gating and fan-out for you, so a provider just forwards the data to its backend. + +[source,java] +---- +public class MyProvider extends AbstractAnalyticsProvider { + @Override + public String getName() { + return "my-backend"; + } + + @Override + public void trackScreen(String name, String referrer) { + // send to your backend + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return capability == AnalyticsCapability.SCREEN_VIEWS; + } +} +---- + +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-migration]] +=== Migrating from AnalyticsService + +The deprecated `AnalyticsService` continues to compile and now routes through this API. To preserve its historical always-on behaviour -- which predates the consent model -- `AnalyticsService.init` switches the default consent mode to `OPT_OUT`. New code should migrate to `Analytics` and manage consent explicitly: + +[options="header",cols="1,1"] +|=== +| Deprecated call | Replacement +| `AnalyticsService.init(agent, domain)` | `Analytics.addProvider(new GoogleAnalyticsProvider(id, secret))` +| `AnalyticsService.visit(page, referer)` | `Analytics.screen(page, referer)` +| `AnalyticsService.sendCrashReport(t, msg, fatal)` | `Analytics.crash(t, msg, fatal)` +| subclassing `AnalyticsService` | implement `AnalyticsProvider` +|=== diff --git a/docs/developer-guide/Miscellaneous-Features.asciidoc b/docs/developer-guide/Miscellaneous-Features.asciidoc index d35fc37d55..b011d2d466 100644 --- a/docs/developer-guide/Miscellaneous-Features.asciidoc +++ b/docs/developer-guide/Miscellaneous-Features.asciidoc @@ -1013,40 +1013,9 @@ The last value is the type of content picked which can be one of: === Analytics integration -One of the features in Codename One is built-in support for analytic instrumentation. Codename One has built-in support for https://www.google.com/analytics/[Google Analytics], which provides reasonable enough statistics of application usage. +Analytics now has its own chapter. It covers the provider SPI, consent and privacy handling, the built-in providers (the Codename One first-party service, Google Analytics 4, Matomo, Firebase and a logging provider), and how to write your own. See the <> chapter. -Analytics is pretty seamless for the old GUI builder since navigation occurs through the Codename One API and can be logged without developer interaction. For example, to begin the instrumentation one needs to add the line: - -[source,java] ----- -AnalyticsService.setAppsMode(true); -AnalyticsService.init(agent, domain); ----- - -To get the value for the agent value create a Google Analytics account and add a domain, then copy and paste the string that looks something like UA-99999999-8 from the console to the agent string. Once this is in place you should start receiving statistic events for the application. - -If your application isn't a GUI builder application or you would like to send more detailed data you can use the `Analytics.visit()` method to show that you're entering a specific page. - -==== Application level analytics - -In 2013 Google introduced an improved application level analytics API that's specifically built for mobile apps. For example, it requires a slightly different API usage. You can activate this specific mode by invoking `setAppsMode(true)`. - -When using this mode you can also report errors and crashes to the Google Analytics server using the `sendCrashReport(Throwable, String message, boolean fatal)` method. - -You recommend using this mode and setting up an apps analytics account as the results are more refined. - -==== Overriding the analytics implementation - -The Analytics API can also be enhanced to support any other form of analytics solution of your own choosing by deriving the `AnalyticsService` class. - -This allows you to integrate with any 3rd party through native or otherwise by overriding methods in the `AnalyticsService` class then invoking: - -[source,java] ----- -AnalyticsService.init(new MyAnalyticsServiceSubclass()); ----- - -Notice that this removes the need to invoke the other `init` method or `setAppsMode(boolean)`. +The `AnalyticsService` class documented here previously is deprecated but still works -- it delegates to the new API. The migration table at the end of the <> chapter maps each old call to its replacement. === Social sign-in (Facebook, Google, ...) diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index b6d7acf2d5..6251b1cb53 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -91,6 +91,8 @@ include::Advertising.asciidoc[] include::Crash-Protection.asciidoc[] +include::Analytics.asciidoc[] + include::Monetization.asciidoc[] include::Apple-Wallet-Extension.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 42844bbd26..99ead435b0 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -622,3 +622,10 @@ transcoder # in the crash-reporting space talks. Shows up in CrashReportPayload # field descriptions. dedup +Matomo +GA4 +CCPA +SPI +pseudonymous +Plausible +PostHog diff --git a/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt b/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt index d6333fc6c7..7f095f23ee 100644 --- a/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt +++ b/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt @@ -12,3 +12,10 @@ Java ME Java SE Java EE [Bb]ackend +Matomo +GA4 +CCPA +SPI +pseudonymous +Plausible +PostHog diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 9d59735734..823a3f7829 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -1670,6 +1670,38 @@ public void usesClassMethod(String cls, String method) { } } + // Firebase Analytics (com.codename1.analytics.FirebaseAnalyticsProvider + // delegates to the NativeFirebaseAnalytics native peer). Enabled with + // the build hint android.firebaseAnalytics=true, which -- like FCM -- + // requires a google-services.json in native/android. Reuses the + // google-services Gradle plugin + buildscript classpath if FCM already + // added them (the contains() guards keep the lines idempotent). + boolean useFirebaseAnalytics = "true".equals(request.getArg("android.firebaseAnalytics", "false")); + if (useFirebaseAnalytics) { + if (!googleServicesJson.exists()) { + error("google-services.json not found. android.firebaseAnalytics=true requires a valid google-services.json in the native/android directory (download it from the Firebase console: https://console.firebase.google.com/).", new RuntimeException()); + return false; + } + if (!request.getArg("android.topDependency", "").contains("com.google.gms:google-services")) { + if (gradleVersionInt >= 8) { + request.putArgument("android.topDependency", request.getArg("android.topDependency", "") + "\n classpath 'com.google.gms:google-services:4.3.15'\n"); + } else { + request.putArgument("android.topDependency", request.getArg("android.topDependency", "") + "\n classpath 'com.google.gms:google-services:4.0.1'\n"); + } + } + if (!request.getArg("android.xgradle", "").contains("apply plugin: 'com.google.gms.google-services'")) { + request.putArgument("android.xgradle", request.getArg("android.xgradle", "") + "\napply plugin: 'com.google.gms.google-services'\n"); + } + if (!request.getArg("gradleDependencies", "").contains("com.google.firebase:firebase-analytics")) { + request.putArgument( + "gradleDependencies", + request.getArg("gradleDependencies", "") + + "\n"+compile+" \"com.google.firebase:firebase-analytics:" + + request.getArg("android.firebaseAnalyticsVersion", "21.5.0") + "\"\n" + ); + } + } + // if a flag is declared we don't want the default play flag to be true diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index ee76f884d4..da88b10445 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -415,6 +415,20 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException iosPods += (((iosPods.length() > 0) ? ",":"") + "Firebase/Core,Firebase/AdMob"); addMinDeploymentTarget("7.0"); } + + // Firebase Analytics (com.codename1.analytics.FirebaseAnalyticsProvider + // delegates to the NativeFirebaseAnalytics native peer). Enabled with + // the build hint ios.firebaseAnalytics=true; requires a + // GoogleService-Info.plist in the project resources. Adds the + // Firebase/Analytics pod (skipped if Firebase/Core was already pulled + // in by AdMob, which carries Analytics transitively). + boolean useFirebaseAnalytics = "true".equals(request.getArg("ios.firebaseAnalytics", "false")); + if (useFirebaseAnalytics && !iosPods.contains("Firebase/")) { + String fbAnalyticsVersion = request.getArg("ios.firebaseAnalyticsVersion", ""); + iosPods += (((iosPods.length() > 0) ? ",":"") + "Firebase/Analytics" + + (fbAnalyticsVersion.length() > 0 ? " " + fbAnalyticsVersion : "")); + addMinDeploymentTarget("10.0"); + } if (enableGalleryMultiselect && photoLibraryUsage) { addMinDeploymentTarget("8.0"); } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsConsentTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsConsentTest.java new file mode 100644 index 0000000000..2098a09a8a --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsConsentTest.java @@ -0,0 +1,67 @@ +package com.codename1.analytics; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; + +import static org.junit.jupiter.api.Assertions.*; + +class AnalyticsConsentTest extends UITestBase { + + @FormTest + void optInDropsEventsUntilConsentGranted() { + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(null); + LoggingAnalyticsProvider provider = new LoggingAnalyticsProvider(); + Analytics.addProvider(provider); + provider.clearLog(); + + Analytics.screen("Home", null); + Analytics.event(AnalyticsEvent.create("ping").build()); + assertTrue(provider.getLog().isEmpty(), "opt-in must drop events before consent"); + + Analytics.setConsent(AnalyticsConsent.granted()); + provider.clearLog(); + Analytics.screen("Home", null); + assertEquals(1, provider.getLog().size()); + assertEquals("screen:Home", provider.getLog().get(0)); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } + + @FormTest + void optOutSendsByDefault() { + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + LoggingAnalyticsProvider provider = new LoggingAnalyticsProvider(); + Analytics.addProvider(provider); + provider.clearLog(); + + Analytics.event(AnalyticsEvent.create("ping").build()); + assertEquals(1, provider.getLog().size()); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } + + @FormTest + void crashRequiresCrashConsentCategory() { + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).crashReporting(false).build()); + LoggingAnalyticsProvider provider = new LoggingAnalyticsProvider(); + Analytics.addProvider(provider); + provider.clearLog(); + + Analytics.crash(new RuntimeException("boom"), "boom", true); + assertTrue(provider.getLog().isEmpty(), "crash must be dropped without crash consent"); + + Analytics.screen("Home", null); + assertEquals(1, provider.getLog().size(), "analytics consent still allows screen views"); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } +} 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 new file mode 100644 index 0000000000..211bb463a4 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java @@ -0,0 +1,94 @@ +package com.codename1.analytics; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for the {@link Analytics} facade: provider management, multi-provider +/// fan-out, the pseudonymous client id, and the personalization consent gate. +class AnalyticsFacadeTest extends UITestBase { + + @FormTest + void addRemoveAndClearProviders() { + Analytics.clearProviders(); + LoggingAnalyticsProvider a = new LoggingAnalyticsProvider(); + LoggingAnalyticsProvider b = new LoggingAnalyticsProvider(); + Analytics.addProvider(a); + Analytics.addProvider(b); + assertEquals(2, Analytics.getProviders().size()); + Analytics.removeProvider(a); + assertEquals(1, Analytics.getProviders().size()); + Analytics.clearProviders(); + assertEquals(0, Analytics.getProviders().size()); + Analytics.setConsent(null); + } + + @FormTest + void screenFansOutToEveryProvider() { + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + LoggingAnalyticsProvider a = new LoggingAnalyticsProvider(); + LoggingAnalyticsProvider b = new LoggingAnalyticsProvider(); + Analytics.addProvider(a); + Analytics.addProvider(b); + a.clearLog(); + b.clearLog(); + + Analytics.screen("Home", null); + assertEquals(1, a.getLog().size()); + assertEquals(1, b.getLog().size()); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } + + @FormTest + void clientIdIsStableAndResettable() { + String id = Analytics.clientId(); + assertNotNull(id); + assertEquals(32, id.length()); + assertEquals(id, Analytics.clientId(), "client id must be stable across calls"); + + String reset = Analytics.resetClientId(); + assertNotNull(reset); + assertNotEquals(id, reset, "reset must produce a new client id"); + assertEquals(reset, Analytics.clientId()); + } + + @FormTest + void setUserIdRequiresPersonalizationConsent() { + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).personalization(false).build()); + LoggingAnalyticsProvider p = new LoggingAnalyticsProvider(); + Analytics.addProvider(p); + p.clearLog(); + + Analytics.setUserId("user-1"); + assertTrue(p.getLog().isEmpty(), "user id must be dropped without personalization consent"); + + Analytics.setConsent(AnalyticsConsent.granted()); + p.clearLog(); + Analytics.setUserId("user-1"); + assertEquals(1, p.getLog().size()); + assertEquals("userId:user-1", p.getLog().get(0)); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } + + @FormTest + void consentPersistsAcrossReload() { + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.granted()); + AnalyticsConsent loaded = Analytics.getConsent(); + assertNotNull(loaded); + assertTrue(loaded.isAnalytics()); + Analytics.setConsent(null); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsModelTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsModelTest.java new file mode 100644 index 0000000000..f92febbf73 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsModelTest.java @@ -0,0 +1,101 @@ +package com.codename1.analytics; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/// Pure-model tests for the analytics value objects and provider capability +/// reporting. These need no simulator, so they extend nothing. +class AnalyticsModelTest { + + @Test + void eventBuilderCarriesNameCategoryAndParams() { + AnalyticsEvent e = AnalyticsEvent.create("purchase") + .category("commerce") + .param("sku", "abc-123") + .param("value", 9.99) + .timestamp(42L) + .build(); + assertEquals("purchase", e.getName()); + assertEquals("commerce", e.getCategory()); + assertEquals("abc-123", e.getParameters().get("sku")); + assertEquals(9.99, ((Number) e.getParameters().get("value")).doubleValue(), 0.0001); + assertEquals(42L, e.getTimestamp()); + } + + @Test + void eventParametersAreUnmodifiable() { + AnalyticsEvent e = AnalyticsEvent.create("x").param("a", "b").build(); + try { + e.getParameters().put("c", "d"); + fail("parameters must be unmodifiable"); + } catch (UnsupportedOperationException expected) { + // ok + } + } + + @Test + void consentGrantedAndDenied() { + AnalyticsConsent g = AnalyticsConsent.granted(); + assertTrue(g.isAnalytics()); + assertTrue(g.isCrashReporting()); + assertTrue(g.isPersonalization()); + assertTrue(g.isAdStorage()); + + AnalyticsConsent d = AnalyticsConsent.denied(); + assertFalse(d.isAnalytics()); + assertFalse(d.isCrashReporting()); + } + + @Test + void consentBuilderAndAsBuilderToggleOneCategory() { + AnalyticsConsent c = AnalyticsConsent.builder() + .analytics(true).crashReporting(false).build(); + assertTrue(c.isAnalytics()); + assertFalse(c.isCrashReporting()); + + AnalyticsConsent c2 = c.asBuilder().crashReporting(true).build(); + assertTrue(c2.isAnalytics()); + assertTrue(c2.isCrashReporting()); + } + + @Test + void crashReportCarriesThrowableAndCustomKeys() { + RuntimeException ex = new RuntimeException("boom"); + AnalyticsCrashReport r = AnalyticsCrashReport.create(ex, "boom", true) + .asBuilder().customKey("screen", "Home").build(); + assertSame(ex, r.getThrowable()); + assertEquals("boom", r.getMessage()); + assertTrue(r.isFatal()); + assertEquals("Home", r.getCustomKeys().get("screen")); + } + + @Test + void providerCapabilitiesAreReportedCorrectly() { + assertTrue(new LoggingAnalyticsProvider().supports(AnalyticsCapability.RAW_EXPORT)); + + GoogleAnalyticsProvider ga = new GoogleAnalyticsProvider("G-X", "s"); + assertTrue(ga.supports(AnalyticsCapability.SCREEN_VIEWS)); + assertTrue(ga.supports(AnalyticsCapability.USER_PROPERTIES)); + assertFalse(ga.supports(AnalyticsCapability.RAW_EXPORT)); + + MatomoAnalyticsProvider matomo = new MatomoAnalyticsProvider("https://m.example.com", 1); + assertTrue(matomo.supports(AnalyticsCapability.SCREEN_VIEWS)); + assertFalse(matomo.supports(AnalyticsCapability.USER_PROPERTIES)); + + CodenameOneAnalyticsProvider cn1 = new CodenameOneAnalyticsProvider(); + assertTrue(cn1.supports(AnalyticsCapability.RAW_EXPORT)); + } + + @Test + void abstractProviderDefaultsToNoCapabilities() { + AnalyticsProvider p = new AbstractAnalyticsProvider() { + @Override + public String getName() { + return "test"; + } + }; + assertFalse(p.supports(AnalyticsCapability.SCREEN_VIEWS)); + assertEquals("test", p.getName()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsServiceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsServiceTest.java index eac3d8f4fa..8d9aab9277 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsServiceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsServiceTest.java @@ -16,6 +16,7 @@ void testVisitQueuesAnalyticsRequest() { implementation.clearQueuedRequests(); AnalyticsService.init("UA-1", "app.example.com"); AnalyticsService.setAppsMode(true); + Analytics.setConsent(null); AnalyticsService.visit("Home", "/"); @@ -32,6 +33,7 @@ void testCrashReportQueued() { implementation.clearQueuedRequests(); AnalyticsService.init("UA-2", "app.example.com"); AnalyticsService.setAppsMode(true); + Analytics.setConsent(null); AnalyticsService.sendCrashReport(new RuntimeException("boom"), "failure", true); List requests = implementation.getQueuedRequests(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/CodenameOneAnalyticsProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/CodenameOneAnalyticsProviderTest.java new file mode 100644 index 0000000000..182d3828d6 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/CodenameOneAnalyticsProviderTest.java @@ -0,0 +1,61 @@ +package com.codename1.analytics; + +import com.codename1.io.ConnectionRequest; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class CodenameOneAnalyticsProviderTest extends UITestBase { + + @FormTest + void flushPostsBatchToCloudEndpoint() { + implementation.clearQueuedRequests(); + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + CodenameOneAnalyticsProvider provider = new CodenameOneAnalyticsProvider(); + Analytics.addProvider(provider); + + Analytics.screen("Home", "Splash"); + Analytics.event(AnalyticsEvent.create("purchase").param("value", 9.99).build()); + // Nothing is sent until the batch flushes. + assertEquals(0, implementation.getQueuedRequests().size()); + + Analytics.flush(); + + List requests = implementation.getQueuedRequests(); + assertEquals(1, requests.size()); + ConnectionRequest request = requests.get(0); + assertTrue(request.getUrl().contains("/api/v2/analytics/events")); + assertTrue(request.isPost()); + String body = request.getRequestBody(); + assertTrue(body.contains("\"consentAnalytics\":true")); + assertTrue(body.contains("screen_view")); + assertTrue(body.contains("purchase")); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } + + @FormTest + void autoFlushesWhenBatchFills() { + implementation.clearQueuedRequests(); + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + CodenameOneAnalyticsProvider provider = new CodenameOneAnalyticsProvider(); + provider.setBatchSize(2); + Analytics.addProvider(provider); + + Analytics.screen("A", null); + assertEquals(0, implementation.getQueuedRequests().size()); + Analytics.screen("B", null); + assertEquals(1, implementation.getQueuedRequests().size(), "filling the batch must auto-flush"); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/FirebaseAnalyticsProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/FirebaseAnalyticsProviderTest.java new file mode 100644 index 0000000000..d1395f3959 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/FirebaseAnalyticsProviderTest.java @@ -0,0 +1,36 @@ +package com.codename1.analytics; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/// In the simulator there is no native Firebase peer, so the provider must +/// degrade to a no-op: it accepts every call without error and queues no +/// network requests. +class FirebaseAnalyticsProviderTest extends UITestBase { + + @FormTest + void degradesToNoOpWithoutNativePeer() { + implementation.clearQueuedRequests(); + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + FirebaseAnalyticsProvider provider = new FirebaseAnalyticsProvider(); + Analytics.addProvider(provider); + + Analytics.screen("Home", null); + Analytics.event(AnalyticsEvent.create("purchase").param("value", 1).build()); + Analytics.setUserId("u1"); + Analytics.setUserProperty("plan", "pro"); + Analytics.crash(new RuntimeException("boom"), "boom", true); + Analytics.flush(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "firebase provider must not queue HTTP requests"); + assertTrue(provider.supports(AnalyticsCapability.CRASH_REPORTING)); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/GoogleAnalyticsProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/GoogleAnalyticsProviderTest.java new file mode 100644 index 0000000000..1c78a60189 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/GoogleAnalyticsProviderTest.java @@ -0,0 +1,82 @@ +package com.codename1.analytics; + +import com.codename1.io.ConnectionRequest; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class GoogleAnalyticsProviderTest extends UITestBase { + + @FormTest + void screenViewPostsGa4Payload() { + implementation.clearQueuedRequests(); + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + Analytics.addProvider(new GoogleAnalyticsProvider("G-TEST", "secret")); + + Analytics.screen("Home", "Splash"); + + List requests = implementation.getQueuedRequests(); + assertEquals(1, requests.size()); + ConnectionRequest request = requests.get(0); + assertTrue(request.getUrl().contains("google-analytics")); + assertTrue(request.getUrl().contains("measurement_id=G-TEST")); + assertTrue(request.isPost()); + String body = request.getRequestBody(); + assertNotNull(body); + assertTrue(body.contains("\"client_id\"")); + assertTrue(body.contains("screen_view")); + assertTrue(body.contains("Home")); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } + + @FormTest + void eventIncludesUserPropertiesAndSanitizedName() { + implementation.clearQueuedRequests(); + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + GoogleAnalyticsProvider provider = new GoogleAnalyticsProvider("G-TEST", "secret"); + Analytics.addProvider(provider); + + Analytics.setUserProperty("plan", "pro"); + Analytics.event(AnalyticsEvent.create("add to cart").param("value", 5).build()); + + List requests = implementation.getQueuedRequests(); + assertEquals(1, requests.size()); + String body = requests.get(0).getRequestBody(); + assertTrue(body.contains("user_properties")); + assertTrue(body.contains("\"plan\"")); + // "add to cart" must be sanitised to a GA4-legal event name. + assertTrue(body.contains("add_to_cart")); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } + + @FormTest + void crashPostsAppException() { + implementation.clearQueuedRequests(); + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + Analytics.addProvider(new GoogleAnalyticsProvider("G-TEST", "secret")); + + Analytics.crash(new RuntimeException("boom"), "boom", true); + + List requests = implementation.getQueuedRequests(); + assertEquals(1, requests.size()); + String body = requests.get(0).getRequestBody(); + assertTrue(body.contains("app_exception")); + assertTrue(body.contains("\"fatal\":true")); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/MatomoAnalyticsProviderTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/MatomoAnalyticsProviderTest.java new file mode 100644 index 0000000000..4e3f4408eb --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/MatomoAnalyticsProviderTest.java @@ -0,0 +1,50 @@ +package com.codename1.analytics; + +import com.codename1.io.ConnectionRequest; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class MatomoAnalyticsProviderTest extends UITestBase { + + @FormTest + void screenViewQueuesMatomoTrackerRequest() { + implementation.clearQueuedRequests(); + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + Analytics.addProvider(new MatomoAnalyticsProvider("https://matomo.example.com", 7)); + + Analytics.screen("Home", null); + + List requests = implementation.getQueuedRequests(); + assertEquals(1, requests.size()); + ConnectionRequest request = requests.get(0); + assertTrue(request.getUrl().contains("matomo.php")); + assertFalse(request.isPost()); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } + + @FormTest + void eventQueuesMatomoTrackerRequest() { + implementation.clearQueuedRequests(); + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + Analytics.addProvider(new MatomoAnalyticsProvider("https://matomo.example.com/matomo.php", 7)); + + Analytics.event(AnalyticsEvent.create("purchase").category("commerce").param("value", 9.99).build()); + + List requests = implementation.getQueuedRequests(); + assertEquals(1, requests.size()); + assertTrue(requests.get(0).getUrl().contains("matomo.php")); + + Analytics.clearProviders(); + Analytics.setConsent(null); + } +} From d494beba4b905cfc36883c4e2b426358717963a8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:43:39 +0300 Subject: [PATCH 2/5] Analytics: avoid StringBuilder.substring (not in CLDC11 core API) The CN1 core (CodenameOne/src) compiles against the CLDC11 bootclasspath, whose StringBuilder has no substring(int,int). Convert to String first in GoogleAnalyticsProvider.sanitizeName so the core/CLDC11 build compiles. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/codename1/analytics/GoogleAnalyticsProvider.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/GoogleAnalyticsProvider.java b/CodenameOne/src/com/codename1/analytics/GoogleAnalyticsProvider.java index 843c15e700..f1191fdc00 100644 --- a/CodenameOne/src/com/codename1/analytics/GoogleAnalyticsProvider.java +++ b/CodenameOne/src/com/codename1/analytics/GoogleAnalyticsProvider.java @@ -234,9 +234,10 @@ private static String sanitizeName(String name) { if (!((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_')) { b.insert(0, '_'); } - if (b.length() > 40) { - return b.substring(0, 40); + String result = b.toString(); + if (result.length() > 40) { + return result.substring(0, 40); } - return b.toString(); + return result; } } From 3b2a733d19a6db8397d7fa518cb2c5a88a6761b2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:17:56 +0300 Subject: [PATCH 3/5] Analytics: drop dead legacy fields (SpotBugs URF_UNREAD_FIELD gate) The deprecated AnalyticsService now delegates to Analytics, leaving domain / timeout / readTimeout written but never read -- SpotBugs failed the JDK 8 gate on URF_UNREAD_FIELD. Remove the fields; the setters become documented no-ops and the init domain parameter is retained for source compatibility only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/codename1/analytics/AnalyticsService.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/AnalyticsService.java b/CodenameOne/src/com/codename1/analytics/AnalyticsService.java index a0ef5fb248..973111e1dc 100644 --- a/CodenameOne/src/com/codename1/analytics/AnalyticsService.java +++ b/CodenameOne/src/com/codename1/analytics/AnalyticsService.java @@ -51,10 +51,7 @@ public class AnalyticsService { private static AnalyticsService instance; private static boolean appsMode = true; private static boolean failSilently = true; - private static int timeout; - private static int readTimeout; private String agent; - private String domain; /// Indicates whether analytics server failures should brodcast an error event /// @@ -115,7 +112,8 @@ public static void setAppsMode(boolean aAppsMode) { /// @deprecated use {@link Analytics} @Deprecated public static void setTimeout(int ms) { - timeout = ms; + // No-op: retained for source compatibility. Timeouts are now managed + // per-provider; configure them on your AnalyticsProvider instead. } /// Retained for source compatibility; no longer affects behaviour. @@ -127,7 +125,8 @@ public static void setTimeout(int ms) { /// @deprecated use {@link Analytics} @Deprecated public static void setReadTimeout(int ms) { - readTimeout = ms; + // No-op: retained for source compatibility. Timeouts are now managed + // per-provider; configure them on your AnalyticsProvider instead. } /// Indicates whether analytics is enabled for this application @@ -160,7 +159,9 @@ public static void init(String agent, String domain) { instance = new AnalyticsService(); } instance.agent = agent; - instance.domain = domain; + // The legacy `domain` parameter is retained in the signature for + // source compatibility but is no longer used -- GA4 identifies the + // app by measurement id, not by a UTM-style domain. Analytics.setConsentMode(ConsentMode.OPT_OUT); Analytics.clearProviders(); Analytics.addProvider(new GoogleAnalyticsProvider(agent, "")); From b38ac978f1a65506e41b41f56a1719e08de8d1a7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:43:52 +0300 Subject: [PATCH 4/5] Analytics: satisfy PMD forbidden-rules gate Make Analytics final (ClassWithOnlyPrivateConstructorsShouldBeFinal) and drop the redundant public modifiers on NativeFirebaseAnalytics interface methods (UnnecessaryModifier). Verified against the full forbidden PMD rule set via mvn verify -- zero forbidden violations across the analytics sources. Co-Authored-By: Claude Opus 4.8 (1M context) --- CodenameOne/src/com/codename1/analytics/Analytics.java | 2 +- .../com/codename1/analytics/NativeFirebaseAnalytics.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index e706648b22..4493140aa7 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -59,7 +59,7 @@ /// {@link Preferences} so they survive restarts. The client id is not derived /// from any hardware identifier and can be cleared with {@link #resetClientId()} /// to honour an erasure request. -public class Analytics { +public final class Analytics { private static final String PREF_CLIENT_ID = "cn1$analyticsClientId"; private static final String PREF_CONSENT_SET = "cn1$analyticsConsentSet"; private static final String PREF_CONSENT_ANALYTICS = "cn1$analyticsConsentAnalytics"; diff --git a/CodenameOne/src/com/codename1/analytics/NativeFirebaseAnalytics.java b/CodenameOne/src/com/codename1/analytics/NativeFirebaseAnalytics.java index 517387efc2..dae4241e0b 100644 --- a/CodenameOne/src/com/codename1/analytics/NativeFirebaseAnalytics.java +++ b/CodenameOne/src/com/codename1/analytics/NativeFirebaseAnalytics.java @@ -48,21 +48,21 @@ public interface NativeFirebaseAnalytics extends NativeInterface { /// - `name`: the event name /// /// - `paramsJson`: a JSON object of parameters, may be empty - public void logEvent(String name, String paramsJson); + void logEvent(String name, String paramsJson); /// Logs a screen view. /// /// #### Parameters /// /// - `screenName`: the screen name - public void logScreen(String screenName); + void logScreen(String screenName); /// Sets the Firebase user id. /// /// #### Parameters /// /// - `id`: the user id, or null to clear - public void setUserId(String id); + void setUserId(String id); /// Sets a Firebase user property. /// @@ -71,5 +71,5 @@ public interface NativeFirebaseAnalytics extends NativeInterface { /// - `key`: the property name /// /// - `value`: the property value - public void setUserProperty(String key, String value); + void setUserProperty(String key, String value); } From 2422a15ce4adc67d1d474f7719cd4a9a095e2400 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:14:35 +0300 Subject: [PATCH 5/5] docs: fix LanguageTool gate in Analytics chapter (US spelling, Piwik) CI LanguageTool (rendered-HTML gate, not runnable locally) flagged British spellings 'behavioural'/'anonymisation' and the proper noun 'Piwik'. Switch to US spelling (behavioral/anonymization, plus honor) and accept-list Piwik. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developer-guide/Analytics.asciidoc | 8 ++++---- docs/developer-guide/languagetool-accept.txt | 1 + .../styles/config/vocabularies/CodenameOne/accept.txt | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index 4644cd16fb..8dd44149fa 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -45,7 +45,7 @@ The API is designed to help you comply with privacy regulations such as GDPR and Analytics.setConsent(AnalyticsConsent.granted()); ---- -Consent is broken down by category, so you can honour granular choices -- for example allowing crash reporting while declining behavioural analytics: +Consent is broken down by category, so you can honor granular choices -- for example allowing crash reporting while declining behavioral analytics: [source,java] ---- @@ -67,7 +67,7 @@ Analytics.setConsentMode(ConsentMode.OPT_OUT); ==== Client identity and erasure -Each device is identified by a pseudonymous client id. It's generated on first use and stored locally -- it isn't derived from any hardware identifier. To honour an erasure request ("right to be forgotten"), reset it: +Each device is identified by a pseudonymous client id. It's generated on first use and stored locally -- it isn't derived from any hardware identifier. To honor an erasure request ("right to be forgotten"), reset it: [source,java] ---- @@ -102,7 +102,7 @@ Screen views are sent as the GA4 `screen_view` event and crashes as `app_excepti ==== Matomo -`MatomoAnalyticsProvider` targets Matomo (formerly Piwik) through its HTTP tracking API. Matomo can be self-hosted and supports IP anonymisation, which makes it a good fit for privacy-sensitive deployments: +`MatomoAnalyticsProvider` targets Matomo (formerly Piwik) through its HTTP tracking API. Matomo can be self-hosted and supports IP anonymization, which makes it a good fit for privacy-sensitive deployments: [source,java] ---- @@ -166,7 +166,7 @@ The `init(AnalyticsContext)` callback hands you the app name, version, platform, [[analytics-migration]] === Migrating from AnalyticsService -The deprecated `AnalyticsService` continues to compile and now routes through this API. To preserve its historical always-on behaviour -- which predates the consent model -- `AnalyticsService.init` switches the default consent mode to `OPT_OUT`. New code should migrate to `Analytics` and manage consent explicitly: +The deprecated `AnalyticsService` continues to compile and now routes through this API. To preserve its historical always-on behavior -- which predates the consent model -- `AnalyticsService.init` switches the default consent mode to `OPT_OUT`. New code should migrate to `Analytics` and manage consent explicitly: [options="header",cols="1,1"] |=== diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 99ead435b0..92c554d267 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -629,3 +629,4 @@ SPI pseudonymous Plausible PostHog +Piwik diff --git a/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt b/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt index 7f095f23ee..15ec20ff5d 100644 --- a/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt +++ b/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt @@ -19,3 +19,4 @@ SPI pseudonymous Plausible PostHog +Piwik