diff --git a/CHANGELOG.md b/CHANGELOG.md index b103a9b8..64197f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ``` Versions released before this one stay available on Maven Central. The files are signed with the same key as before (`FC8DB0DE234A273BA45E562FB8C83A079A5BBD0C`). * ! Minor breaking change ! SDK behavior settings are now enabled by default. The SDK fetches them from the server at initialization and every few hours, so changes made in SDK Manager > SDK Configuration on your server affect SDK behavior directly. They are not used in backend mode. +* ! Minor breaking change ! The feature interfaces of "Countly.instance()" ("events()", "views()", "crashes()", "location()", "userProfile()", "remoteConfig()", "feedback()", "content()" and "deviceId()") no longer return null. Before initialization, while the consent of a feature is not given, or when a feature is not enabled, they return an interface that ignores calls until the feature runs and passes an error to the callbacks of the calls it ignores. An interface you keep sends its calls to the feature once it runs. Use "Countly.isTracking" to check whether a feature runs. * Added support for SDK behavior settings that let the server control the SDK: * Switches for tracking, networking, sessions, views, custom events, crash reporting, automatic crash reporting, location, and console logging @@ -41,6 +42,10 @@ * Updated JSON library version from "20250517" to "20260814". * Mitigated an issue where requests, sessions or crashes created close together could get the same storage ID, so some of them were lost. +* Mitigated an issue where removing the consent of one feature also stopped the other features and deleted their queued events, unsent crashes and remote config values, after which the device ID and user profile interfaces could throw. +* Mitigated an issue where, while consent was required, unhandled crashes were not recorded if the crash reporting consent was given after initialization. +* Mitigated an issue where removing the location consent did not erase the location on the server. +* Mitigated an issue where an error in one feature during a timer tick stopped the SDK timer, and with it session updates and event sending. * Mitigated an issue where verbose logs showed the values of custom network request headers. Only the number of headers is logged now. ## 26.8.0 diff --git a/sdk-java/src/main/java/ly/count/sdk/java/Countly.java b/sdk-java/src/main/java/ly/count/sdk/java/Countly.java index 86486768..9bbe4fe6 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/Countly.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/Countly.java @@ -396,14 +396,15 @@ public static void onConsentRemoval(Config.Feature... features) { * Feedback interface to use feedback widgets nps,rating and forms. * And do manual reporting of feedbacks. * - * @return {@link ModuleFeedback.Feedback} instance. + * @return {@link ModuleFeedback.Feedback} instance, never {@code null}: before init, or while the feature does not + * run, as its consent is not given or it is not enabled, one that ignores calls until it runs */ public ModuleFeedback.Feedback feedback() { if (!isInitialized()) { if (L != null) { L.e("[Countly] SDK is not initialized yet."); } - return null; + return SDKCore.dormantFeedback; } return sdk.feedback(); } @@ -413,7 +414,8 @@ public ModuleFeedback.Feedback feedback() { * content block. A {@link ly.count.sdk.java.internal.ContentDisplay} has to be registered * before a content zone can be entered; the "ly.count.sdk:java-ui" artifact ships a JavaFX one. * - * @return {@link ModuleContent.Content} instance. + * @return {@link ModuleContent.Content} instance, never {@code null}: before init, or while the feature does not + * run, as its consent is not given or it is not enabled, one that ignores calls until it runs * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes */ public ModuleContent.Content content() { @@ -421,7 +423,7 @@ public ModuleContent.Content content() { if (L != null) { L.e("[Countly] content, SDK is not initialized yet."); } - return null; + return SDKCore.dormantContent; } return sdk.content(); } @@ -429,14 +431,15 @@ public ModuleContent.Content content() { /** * RemoteConfig interface to use remote config feature. * - * @return {@link ModuleRemoteConfig.RemoteConfig} instance. + * @return {@link ModuleRemoteConfig.RemoteConfig} instance, never {@code null}: before init, or while the feature does not + * run, as its consent is not given or it is not enabled, one that ignores calls until it runs */ public ModuleRemoteConfig.RemoteConfig remoteConfig() { if (!isInitialized()) { if (L != null) { L.e("[Countly] remoteConfig, SDK is not initialized yet."); } - return null; + return SDKCore.dormantRemoteConfig; } return sdk.remoteConfig(); } @@ -444,14 +447,15 @@ public ModuleRemoteConfig.RemoteConfig remoteConfig() { /** * DeviceId interface to use device id functionalities. * - * @return {@link ModuleDeviceIdCore.DeviceId} instance. + * @return {@link ModuleDeviceIdCore.DeviceId} instance, never {@code null}: before init, or while the feature does not + * run, as its consent is not given or it is not enabled, one that ignores calls until it runs */ public ModuleDeviceIdCore.DeviceId deviceId() { if (!isInitialized()) { if (L != null) { L.e("[Countly] SDK is not initialized yet."); } - return null; + return SDKCore.dormantDeviceId; } return sdk.deviceId(); } @@ -472,14 +476,15 @@ public Event event(String key) { /** * Event module calls * - * @return event module otherwise null if SDK is not initialized + * @return {@link ModuleEvents.Events} instance, never {@code null}: before init, or while the feature does not + * run, as its consent is not given or it is not enabled, one that ignores calls until it runs */ public ModuleEvents.Events events() { if (!isInitialized()) { if (L != null) { L.e("[Countly] SDK is not initialized yet."); } - return null; + return SDKCore.dormantEvents; } return sdk.events(); } @@ -487,14 +492,15 @@ public ModuleEvents.Events events() { /** * Crash module calls * - * @return crash module otherwise null if SDK is not initialized + * @return {@link ModuleCrashes.Crashes} instance, never {@code null}: before init, or while the feature does not + * run, as its consent is not given or it is not enabled, one that ignores calls until it runs */ public ModuleCrashes.Crashes crashes() { if (!isInitialized()) { if (L != null) { L.e("[Countly] SDK is not initialized yet."); } - return null; + return SDKCore.dormantCrashes; } return sdk.crashes(); } @@ -502,14 +508,15 @@ public ModuleCrashes.Crashes crashes() { /** * Views interface to use views feature. * - * @return {@link ModuleViews.Views} instance. + * @return {@link ModuleViews.Views} instance, never {@code null}: before init, or while the feature does not + * run, as its consent is not given or it is not enabled, one that ignores calls until it runs */ public ModuleViews.Views views() { if (!isInitialized()) { if (L != null) { L.e("[Countly] SDK is not initialized yet."); } - return null; + return SDKCore.dormantViews; } return sdk.views(); } @@ -530,14 +537,15 @@ public Event timedEvent(String key) { /** * UserProfile interface to use user profile feature. * - * @return {@link ModuleUserProfile.UserProfile} instance. + * @return {@link ModuleUserProfile.UserProfile} instance, never {@code null}: before init, or while the feature does not + * run, as its consent is not given or it is not enabled, one that ignores calls until it runs */ public ModuleUserProfile.UserProfile userProfile() { if (!isInitialized()) { if (L != null) { L.e("[Countly] userProfile, SDK is not initialized yet."); } - return null; + return SDKCore.dormantUserProfile; } return sdk.userProfile(); } @@ -545,7 +553,8 @@ public ModuleUserProfile.UserProfile userProfile() { /** * Location interface to use location feature. * - * @return {@link ModuleLocation.Location} instance. + * @return {@link ModuleLocation.Location} instance, never {@code null}: before init, or while the feature does not + * run, as its consent is not given or it is not enabled, one that ignores calls until it runs */ public ModuleLocation.Location location() { if (!isInitialized()) { @@ -553,7 +562,7 @@ public ModuleLocation.Location location() { if (L != null) { L.e("Countly.sharedInstance().init must be called before accessing location"); } - return null; + return SDKCore.dormantLocation; } return sdk.location(); @@ -574,25 +583,25 @@ public User user() { @Override public Usage addParam(String key, Object value) { - L.d("[Countly] addParam: key = " + key + " value = " + value); + L.d("[Countly] addParam: key = " + key); return ((Session) sdk.session(null)).addParam(key, value); } @Override public Usage addCrashReport(Throwable t, boolean fatal) { - L.d("[Countly] addCrashReport: t = " + t + " fatal = " + fatal); + L.d("[Countly] addCrashReport: fatal = " + fatal); return ((Session) sdk.session(null)).addCrashReport(t, fatal); } @Override public Usage addCrashReport(Throwable t, boolean fatal, String name, Map segments, String... logs) { - L.d("[Countly] addCrashReport: t = " + t + " fatal = " + fatal + " name = " + name + " segments = " + segments + " logs = " + logs); + L.d("[Countly] addCrashReport: fatal = " + fatal); return ((Session) sdk.session(null)).addCrashReport(t, fatal, name, segments, logs); } @Override public Usage addLocation(double latitude, double longitude) { - L.d("[Countly] addLocation: latitude = " + latitude + " longitude = " + longitude); + L.d("[Countly] addLocation"); return ((Session) sdk.session(null)).addLocation(latitude, longitude); } @@ -603,7 +612,7 @@ public Usage addLocation(double latitude, double longitude) { */ @Override public View view(String name, boolean start) { - L.d("[Countly] view: name = " + name + " start = " + start); + L.d("[Countly] view: start = " + start); return ((Session) sdk.session(null)).view(name, start); } @@ -614,7 +623,7 @@ public View view(String name, boolean start) { */ @Override public View view(String name) { - L.d("[Countly] view: name = " + name); + L.d("[Countly] view"); return ((Session) sdk.session(null)).view(name); } } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigurationProvider.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigurationProvider.java index 65631d52..21c17d28 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigurationProvider.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigurationProvider.java @@ -52,7 +52,7 @@ public interface ConfigurationProvider { /** * Key {@code cet}. * - * @return whether custom events, the ones whose key does not start with {@code [CLY]_}, may be recorded + * @return whether custom events, the ones whose key is not one of {@link ModuleEvents#INTERNAL_EVENT_KEYS}, may be recorded */ boolean getCustomEventTrackingEnabled(); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/CountlyTimer.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/CountlyTimer.java index f663c614..a79481d7 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/CountlyTimer.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/CountlyTimer.java @@ -68,7 +68,14 @@ protected void startTimer(long timerDelay, long initialDelayMs, Runnable runnabl startTime = 0; } - timerService.scheduleWithFixedDelay(runnable, startTime, delay, TimeUnit.MILLISECONDS); + timerService.scheduleWithFixedDelay(() -> { + try { + runnable.run(); + } catch (Throwable t) { + //a fixed delay schedule ends for good once a run throws + L.e("[CountlyTimer] a timer run failed, [" + t + "]"); + } + }, startTime, delay, TimeUnit.MILLISECONDS); } /** diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleBase.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleBase.java index 0ee557e2..a6c786cb 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleBase.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleBase.java @@ -1,5 +1,7 @@ package ly.count.sdk.java.internal; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import ly.count.sdk.java.Session; /** @@ -13,6 +15,9 @@ * */ public abstract class ModuleBase { + //what a call through an interface while its module does not run hands to its callback + static final String NOT_RUNNING_ERROR = "The feature does not run, as its consent is not given or it is not enabled"; + private boolean active = false; protected Log L = null; @@ -47,6 +52,27 @@ public void init(InternalConfig config) { public void stop(InternalConfig config, boolean clear) { } + /** + * The instance of this module that the SDK runs, which every call through an interface of this + * module goes to: this one, or the running one when the interface was handed out before that + * started or kept from an instance that stopped. A call while none runs, as before init or while + * the consent of the feature is not given, is ignored and logged. + * + * @param call the call, starting with the bracketed name of the interface, for the log line + * @param the module type + * @return the running instance, {@code null} while none runs + */ + @SuppressWarnings("unchecked") + @Nullable + M runningInstance(@Nonnull String call) { + SDKCore core = SDKCore.instance; + ModuleBase running = core == null ? null : core.module(getClass()); + if (running == null && core != null && core.L != null) { + core.L.w(call + ", the feature does not run, as its consent is not given or it is not enabled, ignoring the call"); + } + return (M) running; + } + /** * A method to be used by the module itself to determine if {@link #init(InternalConfig)} initialized it * and haven't been stopped yet by {@link #stop(InternalConfig, boolean)}. diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleContent.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleContent.java index ffe3f299..90721f7b 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleContent.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleContent.java @@ -82,6 +82,7 @@ public void init(InternalConfig config) { contentInterface = new Content(); startedAt = TimeUtils.uniqueTimestampMs(); synchronized (contentLock) { + display = config.sdk.contentDisplay; contentZoneEnabledApplied = config.getConfigurationProvider().getContentZoneEnabled(); } } @@ -417,11 +418,13 @@ void recordContentEventsInternal(String eventsJson) { return; } - ModuleEvents.Events events = SDKCore.instance == null ? null : SDKCore.instance.events(); - if (events == null) { + SDKCore core = SDKCore.instance; + ModuleEvents eventsModule = core == null ? null : core.runningModule(CoreFeature.Events, ModuleEvents.class); + if (eventsModule == null) { L.w("[ModuleContent] recordContentEventsInternal, events are not available, content events are dropped"); return; } + ModuleEvents.Events events = eventsModule.eventsInterface; boolean recorded = false; try { @@ -643,17 +646,39 @@ private void clearFetching(int fetchGeneration) { */ public class Content { + /** + * The interface a call goes to: this one while its module runs, the running module's when + * this one was handed out before that started or kept from one that stopped, and + * {@code null} while the feature does not run. + * + * @param call the call, for the log line + * @return the interface, {@code null} for none + */ + private Content target(String call) { + ModuleContent running = runningInstance(call); + return running == null ? null : running.contentInterface; + } + /** * Register the display that draws content blocks. Required before entering a content zone. * Pass {@code null} to unregister. While the SDK behavior settings ask for the content zone, - * registering a display enters it. + * registering a display enters it. A display registered before the content consent is given + * is used once it is. * * @param contentDisplay the display to draw content with * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes */ public void setContentDisplay(@Nullable ContentDisplay contentDisplay) { synchronized (Countly.instance()) { - setContentDisplayInternal(contentDisplay); + SDKCore core = SDKCore.instance; + if (core == null) { + return; + } + core.contentDisplay = contentDisplay; + ModuleContent running = core.module(ModuleContent.class); + if (running != null) { + running.setContentDisplayInternal(contentDisplay); + } } } @@ -665,6 +690,13 @@ public void setContentDisplay(@Nullable ContentDisplay contentDisplay) { * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes */ public void enterContentZone() { + Content target = target("[Content] enterContentZone"); + if (target != this) { + if (target != null) { + target.enterContentZone(); + } + return; + } synchronized (Countly.instance()) { L.i("[Content] enterContentZone, entering the content zone"); enterContentZoneInternal(); @@ -678,6 +710,13 @@ public void enterContentZone() { * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes */ public void exitContentZone() { + Content target = target("[Content] exitContentZone"); + if (target != this) { + if (target != null) { + target.exitContentZone(); + } + return; + } synchronized (Countly.instance()) { L.i("[Content] exitContentZone, leaving the content zone"); exitContentZoneInternal(); @@ -692,6 +731,13 @@ public void exitContentZone() { * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes */ public void refreshContentZone() { + Content target = target("[Content] refreshContentZone"); + if (target != this) { + if (target != null) { + target.refreshContentZone(); + } + return; + } synchronized (Countly.instance()) { L.i("[Content] refreshContentZone, refreshing the content zone"); refreshContentZoneInternal(); @@ -706,6 +752,13 @@ public void refreshContentZone() { * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes */ public void previewContent(@Nullable String contentId) { + Content target = target("[Content] previewContent"); + if (target != this) { + if (target != null) { + target.previewContent(contentId); + } + return; + } synchronized (Countly.instance()) { L.i("[Content] previewContent, previewing content:[" + contentId + "]"); if (Utils.isEmptyOrNull(contentId)) { @@ -725,6 +778,13 @@ public void previewContent(@Nullable String contentId) { * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes */ public void recordContentEvents(@Nullable String eventsJson) { + Content target = target("[Content] recordContentEvents"); + if (target != this) { + if (target != null) { + target.recordContentEvents(eventsJson); + } + return; + } synchronized (Countly.instance()) { L.d("[Content] recordContentEvents, recording the events of a content block"); recordContentEventsInternal(eventsJson); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleCrashes.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleCrashes.java index 2c1365cc..7801839c 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleCrashes.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleCrashes.java @@ -266,12 +266,32 @@ protected void addBreadcrumbInternal(String record) { public class Crashes { + /** + * The interface a call goes to: this one while its module runs, the running module's when + * this one was handed out before that started or kept from one that stopped, and + * {@code null} while the feature does not run. + * + * @param call the call, for the log line + * @return the interface, {@code null} for none + */ + private Crashes target(String call) { + ModuleCrashes running = runningInstance(call); + return running == null ? null : running.crashInterface; + } + /** * Add crash breadcrumb like log record to the log that will be sent together with crash report * * @param record String a bread crumb for the crash report */ public void addCrashBreadcrumb(String record) { + Crashes target = target("[Crashes] addCrashBreadcrumb"); + if (target != this) { + if (target != null) { + target.addCrashBreadcrumb(record); + } + return; + } synchronized (Countly.instance()) { L.i("[Crashes] Adding crash breadcrumb"); addBreadcrumbInternal(record); @@ -284,6 +304,13 @@ public void addCrashBreadcrumb(String record) { * @param exception Throwable to log */ public void recordHandledException(Throwable exception) { + Crashes target = target("[Crashes] recordHandledException"); + if (target != this) { + if (target != null) { + target.recordHandledException(exception); + } + return; + } synchronized (Countly.instance()) { recordExceptionInternal(exception, true, null, null); } @@ -295,6 +322,13 @@ public void recordHandledException(Throwable exception) { * @param exception Throwable to log */ public void recordUnhandledException(Throwable exception) { + Crashes target = target("[Crashes] recordUnhandledException"); + if (target != this) { + if (target != null) { + target.recordUnhandledException(exception); + } + return; + } synchronized (Countly.instance()) { recordExceptionInternal(exception, false, null, null); } @@ -306,6 +340,13 @@ public void recordUnhandledException(Throwable exception) { * @param exception Throwable to log */ public void recordHandledException(final Throwable exception, final Map customSegmentation) { + Crashes target = target("[Crashes] recordHandledException"); + if (target != this) { + if (target != null) { + target.recordHandledException(exception, customSegmentation); + } + return; + } synchronized (Countly.instance()) { recordExceptionInternal(exception, true, customSegmentation, null); } @@ -317,6 +358,13 @@ public void recordHandledException(final Throwable exception, final Map customSegmentation) { + Crashes target = target("[Crashes] recordUnhandledException"); + if (target != this) { + if (target != null) { + target.recordUnhandledException(exception, customSegmentation); + } + return; + } synchronized (Countly.instance()) { recordExceptionInternal(exception, false, customSegmentation, null); } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleDeviceIdCore.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleDeviceIdCore.java index e77f93c5..5d447f31 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleDeviceIdCore.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleDeviceIdCore.java @@ -237,12 +237,32 @@ protected String getIDInternal() { public class DeviceId { + /** + * The interface a call goes to: this one while its module runs, the running module's when + * this one was handed out before that started or kept from one that stopped, and + * {@code null} while the feature does not run. + * + * @param call the call, for the log line + * @return the interface, {@code null} for none + */ + private DeviceId target(String call) { + ModuleDeviceIdCore running = runningInstance(call); + return running == null ? null : running.deviceIdInterface; + } + /** * Returns current device id. * * @return device id string */ public String getID() { + DeviceId target = target("[DeviceId] getID"); + if (target != this) { + if (target == null) { + return null; + } + return target.getID(); + } synchronized (Countly.instance()) { return getIDInternal(); } @@ -255,6 +275,13 @@ public String getID() { * @param newDeviceID device id to set */ public void setID(String newDeviceID) { + DeviceId target = target("[DeviceId] setID"); + if (target != this) { + if (target != null) { + target.setID(newDeviceID); + } + return; + } synchronized (Countly.instance()) { setIDInternal(newDeviceID); } @@ -266,6 +293,13 @@ public void setID(String newDeviceID) { * @return device id type */ public DeviceIdType getType() { + DeviceId target = target("[DeviceId] getType"); + if (target != this) { + if (target == null) { + return null; + } + return target.getType(); + } synchronized (Countly.instance()) { return getTypeInternal(); } @@ -277,6 +311,13 @@ public DeviceIdType getType() { * @param id new device id string, cannot be empty */ public void changeWithMerge(String id) { + DeviceId target = target("[DeviceId] changeWithMerge"); + if (target != this) { + if (target != null) { + target.changeWithMerge(id); + } + return; + } synchronized (Countly.instance()) { changeDeviceIdInternal(id, DeviceIdType.DEVELOPER_SUPPLIED, true); } @@ -288,6 +329,13 @@ public void changeWithMerge(String id) { * @param id new device id string, cannot be empty */ public void changeWithoutMerge(String id) { + DeviceId target = target("[DeviceId] changeWithoutMerge"); + if (target != this) { + if (target != null) { + target.changeWithoutMerge(id); + } + return; + } synchronized (Countly.instance()) { changeDeviceIdInternal(id, DeviceIdType.DEVELOPER_SUPPLIED, false); } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java index 1be3a0eb..9bcc528a 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java @@ -1,7 +1,10 @@ package ly.count.sdk.java.internal; +import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -12,12 +15,17 @@ import ly.count.sdk.java.View; public class ModuleEvents extends ModuleBase { - static final String INTERNAL_EVENT_KEY_PREFIX = "[CLY]_"; + /** + * The keys of the events the SDK records itself, which the custom event settings of the SDK + * behavior settings do not apply to. Any other key is a custom event, one starting with + * {@code [CLY]_} too. + */ + static final Set INTERNAL_EVENT_KEYS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + ModuleViews.KEY_VIEW_EVENT, FeedbackWidgetType.nps.eventKey, FeedbackWidgetType.survey.eventKey, FeedbackWidgetType.rating.eventKey))); protected EventQueue eventQueue = null; final Map timedEvents = new ConcurrentHashMap<>(); protected Events eventsInterface = null; - ViewIdProvider viewIdProvider = null; IdGenerator idGenerator = null; String previousEventId = null; /** @@ -42,12 +50,6 @@ public void init(InternalConfig config) { idGenerator = config.eventIdGenerator; } - @Override - public void initFinished(InternalConfig config) { - super.initFinished(config); - viewIdProvider = config.viewIdProvider; - } - @Override protected void onTimer() { addEventsToRequestQ(null); @@ -209,7 +211,7 @@ protected void recordEventInternal(String key, int count, Double sum, Double dur return; } - boolean isCustomEvent = !key.startsWith(INTERNAL_EVENT_KEY_PREFIX); + boolean isCustomEvent = !INTERNAL_EVENT_KEYS.contains(key); if (isCustomEvent) { if (!configProvider.getCustomEventTrackingEnabled()) { L.d("[ModuleEvents] recordEventInternal, custom event tracking is disabled by the SDK behavior settings, event [" + key + "] will not be recorded"); @@ -235,9 +237,7 @@ protected void recordEventInternal(String key, int count, Double sum, Double dur segmentation = UtilsInternalLimits.applySegmentationLimits(segmentation, configProvider, L, "[ModuleEvents] recordEventInternal"); } - if (internalConfig.isAutoSendUserProperties() && internalConfig.sdk.userProfile() != null) { - internalConfig.sdk.module(ModuleUserProfile.class).saveInternal(); - } + ModuleUserProfile.saveIfSentAutomatically(internalConfig); String eventId, pvid = null, cvid = null; if (Utils.isEmptyOrNull(eventIdOverride)) { @@ -247,6 +247,7 @@ protected void recordEventInternal(String key, int count, Double sum, Double dur eventId = eventIdOverride; } + ViewIdProvider viewIdProvider = internalConfig.viewIdProvider; if (key.equals(ModuleViews.KEY_VIEW_EVENT)) { pvid = viewIdProvider.getPreviousViewId(); } else { @@ -289,7 +290,7 @@ private Map filteredCustomEventSegmentation(@Nonnull String key, * is in {@code jte}, or a view event whose name is in {@code jtv}. * * @param key the key of the event, as it was passed - * @param isCustomEvent whether the key is outside the {@code [CLY]_} prefix + * @param isCustomEvent whether the key is not one of {@link #INTERNAL_EVENT_KEYS} * @param segmentation the segmentation of the event, {@code null} for none * @param configProvider the settings in effect * @return {@code true} for a journey trigger @@ -408,6 +409,19 @@ boolean cancelEventInternal(final String key) { public class Events { + /** + * The interface a call goes to: this one while its module runs, the running module's when + * this one was handed out before that started or kept from one that stopped, and + * {@code null} while the feature does not run. + * + * @param call the call, for the log line + * @return the interface, {@code null} for none + */ + private Events target(String call) { + ModuleEvents running = runningInstance(call); + return running == null ? null : running.eventsInterface; + } + /** * Record an event. * @@ -418,6 +432,13 @@ public class Events { * @param segmentation additional segmentation data that you want to set, leave null if you don't want to add anything */ public void recordEvent(String key, Map segmentation, int count, Double sum, Double dur) { + Events target = target("[Events] recordEvent"); + if (target != this) { + if (target != null) { + target.recordEvent(key, segmentation, count, sum, dur); + } + return; + } L.i("[Events] recordEvent: key = " + key + ", count = " + count + ", sum = " + sum + ", segmentation = " + segmentation + ", dur = " + dur); recordEventInternal(key, count, sum, dur, segmentation, null); } @@ -498,6 +519,10 @@ public void recordEvent(String key, int count, Double sum) { * @return true if no event with this key existed before and event is started, false otherwise */ public boolean startEvent(final String key) { + Events target = target("[Events] startEvent"); + if (target != this) { + return target != null && target.startEvent(key); + } L.i("[Events] startEvent: key = " + key); return startEventInternal(key); } @@ -509,6 +534,10 @@ public boolean startEvent(final String key) { * @return true if event with this key has been previously started, false otherwise */ public boolean endEvent(final String key) { + Events target = target("[Events] endEvent"); + if (target != this) { + return target != null && target.endEvent(key); + } L.i("[Events] endEvent: key = " + key); return endEventInternal(key, null, 1, null); } @@ -525,6 +554,10 @@ public boolean endEvent(final String key) { * @throws IllegalArgumentException if key is null or empty, count is less than 1, or if segmentation contains null or empty keys or values */ public boolean endEvent(final String key, final Map segmentation, final int count, final Double sum) { + Events target = target("[Events] endEvent"); + if (target != this) { + return target != null && target.endEvent(key, segmentation, count, sum); + } L.i("[Events] endEvent: key = " + key + ", segmentation = " + segmentation + ", count = " + count + ", sum = " + sum); return endEventInternal(key, segmentation, count, sum); } @@ -535,6 +568,10 @@ public boolean endEvent(final String key, final Map segmentation * @return true if event with this key has been previously started, false otherwise **/ public boolean cancelEvent(final String key) { + Events target = target("[Events] cancelEvent"); + if (target != this) { + return target != null && target.cancelEvent(key); + } L.i("[Events] cancelEvent: key = " + key); return cancelEventInternal(key); } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleFeedback.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleFeedback.java index 8b9747a6..f6702c0b 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleFeedback.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleFeedback.java @@ -374,12 +374,34 @@ private String constructFeedbackWidgetUrlInternal(CountlyFeedbackWidget widgetIn public class Feedback { + /** + * The interface a call goes to: this one while its module runs, the running module's when + * this one was handed out before that started or kept from one that stopped, and + * {@code null} while the feature does not run. + * + * @param call the call, for the log line + * @return the interface, {@code null} for none + */ + private Feedback target(String call) { + ModuleFeedback running = runningInstance(call); + return running == null ? null : running.feedbackInterface; + } + /** * Get a list of available feedback widgets for this device ID * * @param callback retrieve widget list callback */ public void getAvailableFeedbackWidgets(@Nullable CallbackOnFinish> callback) { + Feedback target = target("[Feedback] getAvailableFeedbackWidgets"); + if (target != this) { + if (target != null) { + target.getAvailableFeedbackWidgets(callback); + } else if (callback != null) { + callback.onFinished(null, NOT_RUNNING_ERROR); + } + return; + } synchronized (Countly.instance()) { L.i("[Feedback] getAvailableFeedbackWidgets, Trying to retrieve feedback widget list"); @@ -393,6 +415,13 @@ public void getAvailableFeedbackWidgets(@Nullable CallbackOnFinish callback) { + Feedback target = target("[Feedback] getFeedbackWidgetData"); + if (target != this) { + if (target != null) { + target.getFeedbackWidgetData(widgetInfo, callback); + } else if (callback != null) { + callback.onFinished(null, NOT_RUNNING_ERROR); + } + return; + } synchronized (Countly.instance()) { L.i("[Feedback] getFeedbackWidgetData, Trying to retrieve feedback widget data"); @@ -448,6 +493,13 @@ public void getFeedbackWidgetData(@Nullable CountlyFeedbackWidget widgetInfo, @N * @param widgetResult widget result */ public void reportFeedbackWidgetManually(@Nullable CountlyFeedbackWidget widgetInfo, @Nullable JSONObject widgetData, @Nullable Map widgetResult) { + Feedback target = target("[Feedback] reportFeedbackWidgetManually"); + if (target != this) { + if (target != null) { + target.reportFeedbackWidgetManually(widgetInfo, widgetData, widgetResult); + } + return; + } synchronized (Countly.instance()) { L.i("[Feedback] reportFeedbackWidgetManually, Trying to report feedback widget manually"); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleLocation.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleLocation.java index bbf88c98..6f49fd4c 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleLocation.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleLocation.java @@ -29,19 +29,43 @@ public void init(InternalConfig internalConfig) { void disableLocationInternal() { L.d("[ModuleLocation] Calling 'disableLocationInternal'"); + clearLocation(); + sendLocation(); + } + + /** + * Forgets the location and marks it disabled, so the next location request erases it. + */ + private void clearLocation() { country = null; city = null; location = null; ip = null; locationDisabled = true; - sendLocation(); + } + + /** + * Queues a request carrying the location as it is now. + */ + private void pushLocation() { + ModuleRequests.pushAsync(internalConfig, new Request(prepareLocationParams()), true, null); + } + + /** + * Clears the location and erases it on the server right away, without waiting for a session to + * begin, as when the location consent is removed. + */ + void eraseLocation() { + L.d("[ModuleLocation] eraseLocation"); + clearLocation(); + pushLocation(); } void sendLocation() { L.d("[ModuleLocation] Calling 'sendLocation'"); SessionImpl session = internalConfig.sdk.getSession(); if (session == null || session.getBegan() != null || !isLocationSentWithSessionBegin()) { - ModuleRequests.pushAsync(internalConfig, new Request(prepareLocationParams()), true, null); + pushLocation(); } // else case, values are added to the session begin request } @@ -213,10 +237,30 @@ private boolean isLegacyValueAllowed(@Nullable Object value) { public class Location { + /** + * The interface a call goes to: this one while its module runs, the running module's when + * this one was handed out before that started or kept from one that stopped, and + * {@code null} while the feature does not run. + * + * @param call the call, for the log line + * @return the interface, {@code null} for none + */ + private Location target(String call) { + ModuleLocation running = runningInstance(call); + return running == null ? null : running.locationInterface; + } + /** * Disable sending of location data. Erases server side saved location information */ public void disableLocation() { + Location target = target("[Location] disableLocation"); + if (target != this) { + if (target != null) { + target.disableLocation(); + } + return; + } synchronized (Countly.instance()) { L.i("[Location] Calling 'disableLocation'"); @@ -235,6 +279,13 @@ public void disableLocation() { * @param ipAddress ipAddress like "192.168.88.33" */ public void setLocation(@Nullable String countryCode, @Nullable String city, @Nullable String gpsCoordinates, @Nullable String ipAddress) { + Location target = target("[Location] setLocation"); + if (target != this) { + if (target != null) { + target.setLocation(countryCode, city, gpsCoordinates, ipAddress); + } + return; + } synchronized (Countly.instance()) { L.i("[Location] Calling 'setLocation'"); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleRemoteConfig.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleRemoteConfig.java index 41abe3a7..8810618a 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleRemoteConfig.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleRemoteConfig.java @@ -1,8 +1,6 @@ package ly.count.sdk.java.internal; -import java.util.ArrayList; import java.util.Arrays; -import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import javax.annotation.Nonnull; @@ -18,7 +16,6 @@ public class ModuleRemoteConfig extends ModuleBase { // if set to true we should add 'oi=1' to our RC download call boolean autoEnrollEnabled; boolean remoteConfigValuesShouldBeCached = false; - List downloadCallbacks = new ArrayList<>(2); ModuleRemoteConfig() { } @@ -36,7 +33,6 @@ public void init(final InternalConfig config) { remoteConfigValuesShouldBeCached = config.isRemoteConfigValueCachingEnabled(); autoEnrollEnabled = config.isAutoEnrollFlagEnabled(); - downloadCallbacks.addAll(config.getRemoteConfigGlobalCallbackList()); remoteConfigInterface = new RemoteConfig(); } @@ -251,7 +247,7 @@ private void cacheOrClearRCValuesIfNeeded() { } private void notifyDownloadCallbacks(final RCDownloadCallback devProvidedCallback, RequestResult requestResult, String message, boolean fullUpdate, Map downloadedValues) { - downloadCallbacks.forEach(callback -> callback.callback(requestResult, message, fullUpdate, downloadedValues)); + internalConfig.sdk.remoteConfigDownloadCallbacks.forEach(callback -> callback.callback(requestResult, message, fullUpdate, downloadedValues)); if (devProvidedCallback != null) { devProvidedCallback.callback(requestResult, message, fullUpdate, downloadedValues); @@ -309,6 +305,19 @@ public void stop(InternalConfig config, final boolean clear) { public class RemoteConfig { + /** + * The interface a call goes to: this one while its module runs, the running module's when + * this one was handed out before that started or kept from one that stopped, and + * {@code null} while the feature does not run. + * + * @param call the call, for the log line + * @return the interface, {@code null} for none + */ + private RemoteConfig target(String call) { + ModuleRemoteConfig running = runningInstance(call); + return running == null ? null : running.remoteConfigInterface; + } + /** * Manual remote config call that will initiate a download of all except the given remote config keys. * If no keys are provided then it will download all available RC values @@ -317,6 +326,15 @@ public class RemoteConfig { * @param callback This is called when the operation concludes */ public void downloadOmittingKeys(@Nullable String[] keysToOmit, @Nullable final RCDownloadCallback callback) { + RemoteConfig target = target("[RemoteConfig] downloadOmittingKeys"); + if (target != this) { + if (target != null) { + target.downloadOmittingKeys(keysToOmit, callback); + } else if (callback != null) { + callback.callback(RequestResult.Error, NOT_RUNNING_ERROR, keysToOmit == null || keysToOmit.length == 0, null); + } + return; + } synchronized (Countly.instance()) { L.i("[RemoteConfig] downloadOmittingKeys"); @@ -336,6 +354,15 @@ public void downloadOmittingKeys(@Nullable String[] keysToOmit, @Nullable final * @param callback This is called when the operation concludes */ public void downloadSpecificKeys(@Nullable String[] keysToInclude, @Nullable final RCDownloadCallback callback) { + RemoteConfig target = target("[RemoteConfig] downloadSpecificKeys"); + if (target != this) { + if (target != null) { + target.downloadSpecificKeys(keysToInclude, callback); + } else if (callback != null) { + callback.callback(RequestResult.Error, NOT_RUNNING_ERROR, keysToInclude == null || keysToInclude.length == 0, null); + } + return; + } synchronized (Countly.instance()) { L.i("[RemoteConfig] downloadSpecificKeys"); @@ -353,6 +380,15 @@ public void downloadSpecificKeys(@Nullable String[] keysToInclude, @Nullable fin * @param callback This is called when the operation concludes */ public void downloadAllKeys(@Nullable RCDownloadCallback callback) { + RemoteConfig target = target("[RemoteConfig] downloadAllKeys"); + if (target != this) { + if (target != null) { + target.downloadAllKeys(callback); + } else if (callback != null) { + callback.callback(RequestResult.Error, NOT_RUNNING_ERROR, true, null); + } + return; + } synchronized (Countly.instance()) { L.i("[RemoteConfig] downloadAllKeys"); updateRemoteConfigValues(null, null, callback); @@ -365,6 +401,13 @@ public void downloadAllKeys(@Nullable RCDownloadCallback callback) { * @return The available RC values */ public @Nonnull Map getValues() { + RemoteConfig target = target("[RemoteConfig] getValues"); + if (target != this) { + if (target == null) { + return new ConcurrentHashMap<>(); + } + return target.getValues(); + } synchronized (Countly.instance()) { L.i("[RemoteConfig] getValues"); return getRemoteConfigValueStoreInternal().getAllValues(); @@ -377,6 +420,13 @@ public void downloadAllKeys(@Nullable RCDownloadCallback callback) { * @return The available RC values */ public @Nonnull Map getAllValuesAndEnroll() { + RemoteConfig target = target("[RemoteConfig] getAllValuesAndEnroll"); + if (target != this) { + if (target == null) { + return new ConcurrentHashMap<>(); + } + return target.getAllValuesAndEnroll(); + } synchronized (Countly.instance()) { L.i("[RemoteConfig] getAllValuesAndEnroll"); Map values = getRemoteConfigValueStoreInternal().getAllValues(); @@ -399,6 +449,13 @@ public void downloadAllKeys(@Nullable RCDownloadCallback callback) { * @return The returned value. If no value existed for the key then the inner object (value) will be returned as "null" */ public @Nonnull RCData getValue(final @Nullable String key) { + RemoteConfig target = target("[RemoteConfig] getValue"); + if (target != this) { + if (target == null) { + return new RCData(null, true); + } + return target.getValue(key); + } synchronized (Countly.instance()) { L.i("[RemoteConfig] getValue, key:[" + key + "]"); @@ -418,6 +475,13 @@ public void downloadAllKeys(@Nullable RCDownloadCallback callback) { * @return The returned value. If no value existed for the key then the inner object will be returned as "null" */ public @Nonnull RCData getValueAndEnroll(@Nullable String key) { + RemoteConfig target = target("[RemoteConfig] getValueAndEnroll"); + if (target != this) { + if (target == null) { + return new RCData(null, true); + } + return target.getValueAndEnroll(key); + } synchronized (Countly.instance()) { L.i("[RemoteConfig] getValueAndEnroll, key:[" + key + "]"); @@ -443,6 +507,13 @@ public void downloadAllKeys(@Nullable RCDownloadCallback callback) { * @param keys - String array of keys (parameters) */ public void enrollIntoABTestsForKeys(@Nullable String[] keys) { + RemoteConfig target = target("[RemoteConfig] enrollIntoABTestsForKeys"); + if (target != this) { + if (target != null) { + target.enrollIntoABTestsForKeys(keys); + } + return; + } synchronized (Countly.instance()) { L.i("[RemoteConfig] enrollIntoABTestsForKeys"); if (keys == null || keys.length == 0) { @@ -459,6 +530,13 @@ public void enrollIntoABTestsForKeys(@Nullable String[] keys) { * @param keys - String array of keys (parameters) */ public void exitABTestsForKeys(@Nullable String[] keys) { + RemoteConfig target = target("[RemoteConfig] exitABTestsForKeys"); + if (target != this) { + if (target != null) { + target.exitABTestsForKeys(keys); + } + return; + } synchronized (Countly.instance()) { L.i("[RemoteConfig] exitABTestsForKeys"); if (keys == null) { @@ -469,18 +547,23 @@ public void exitABTestsForKeys(@Nullable String[] keys) { } /** - * Register a global callback for when download operations have finished + * Register a global callback for when download operations have finished. It is kept while + * remote config does not run, as before its consent is given, and called once it does. * * @param callback The callback that should be added */ public void registerDownloadCallback(@Nullable final RCDownloadCallback callback) { synchronized (Countly.instance()) { - L.i("[RemoteConfig] registerDownloadCallback"); + SDKCore core = SDKCore.instance; + if (core == null) { + return; + } + core.L.i("[RemoteConfig] registerDownloadCallback"); if (callback == null) { - L.w("[RemoteConfig] registerDownloadCallback, passed callback is null"); + core.L.w("[RemoteConfig] registerDownloadCallback, passed callback is null"); return; } - downloadCallbacks.add(callback); + core.remoteConfigDownloadCallbacks.add(callback); } } @@ -491,12 +574,16 @@ public void registerDownloadCallback(@Nullable final RCDownloadCallback callback */ public void removeDownloadCallback(@Nullable final RCDownloadCallback callback) { synchronized (Countly.instance()) { - L.i("[RemoteConfig] removeDownloadCallback"); + SDKCore core = SDKCore.instance; + if (core == null) { + return; + } + core.L.i("[RemoteConfig] removeDownloadCallback"); if (callback == null) { - L.w("[RemoteConfig] removeDownloadCallback, passed callback is null"); + core.L.w("[RemoteConfig] removeDownloadCallback, passed callback is null"); return; } - downloadCallbacks.remove(callback); + core.remoteConfigDownloadCallbacks.remove(callback); } } @@ -504,6 +591,13 @@ public void removeDownloadCallback(@Nullable final RCDownloadCallback callback) * Clear all stored remote config values. */ public void clearAll() { + RemoteConfig target = target("[RemoteConfig] clearAll"); + if (target != this) { + if (target != null) { + target.clearAll(); + } + return; + } synchronized (Countly.instance()) { L.i("[RemoteConfig] clearAll"); clearValueStoreInternal(); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java index bcbe4366..796b5c63 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java @@ -425,10 +425,27 @@ private void applyCacheLimitToModifications() { L.w("[ModuleUserProfile] applyCacheLimitToModifications, [" + modifiedKeys.size() + "] custom user properties have pending modifications, over the cache limit of [" + cacheLimit + "] set by the SDK behavior settings, dropped the modifications of the oldest ones: " + dropped); } + /** + * Sends the user properties set so far when the SDK sends them automatically, which it does + * before every session and event request. + * + * @param config the configuration of the running SDK + */ + static void saveIfSentAutomatically(@Nonnull InternalConfig config) { + if (!config.isAutoSendUserProperties()) { + return; + } + ModuleUserProfile module = config.sdk.module(ModuleUserProfile.class); + if (module != null) { + module.saveInternal(); + } + } + protected void saveInternal() { Params generatedParams = prepareRequestParamsForUserProfile(); - if (internalConfig.sdk.location() != null) { - internalConfig.sdk.module(ModuleLocation.class).saveLocationToParamsLegacy(generatedParams); + ModuleLocation location = internalConfig.sdk.runningModule(CoreFeature.Location, ModuleLocation.class); + if (location != null) { + location.saveLocationToParamsLegacy(generatedParams); } L.d("[ModuleUserProfile] saveInternal, generated params [" + generatedParams + "]"); @@ -437,8 +454,9 @@ protected void saveInternal() { return; } - if (internalConfig.isAutoSendUserProperties() && internalConfig.sdk.events() != null) { - internalConfig.sdk.module(ModuleEvents.class).checkEventQueueToSend(true); + ModuleEvents events = internalConfig.sdk.runningModule(CoreFeature.Events, ModuleEvents.class); + if (internalConfig.isAutoSendUserProperties() && events != null) { + events.checkEventQueueToSend(true); } ModuleRequests.pushAsync(internalConfig, new Request(generatedParams)); @@ -487,12 +505,32 @@ public void deviceIdChanged(String oldDeviceId, boolean withMerge) { public class UserProfile { + /** + * The interface a call goes to: this one while its module runs, the running module's when + * this one was handed out before that started or kept from one that stopped, and + * {@code null} while the feature does not run. + * + * @param call the call, for the log line + * @return the interface, {@code null} for none + */ + private UserProfile target(String call) { + ModuleUserProfile running = runningInstance(call); + return running == null ? null : running.userProfileInterface; + } + /** * Increment custom property value by 1. * * @param key String with property name to increment */ public void increment(String key) { + UserProfile target = target("[UserProfile] increment"); + if (target != this) { + if (target != null) { + target.increment(key); + } + return; + } synchronized (Countly.instance()) { modifyCustomData(key, 1, Op.INC); } @@ -505,6 +543,13 @@ public void increment(String key) { * @param value int value by which to increment */ public void incrementBy(String key, int value) { + UserProfile target = target("[UserProfile] incrementBy"); + if (target != this) { + if (target != null) { + target.incrementBy(key, value); + } + return; + } synchronized (Countly.instance()) { modifyCustomData(key, value, Op.INC); } @@ -517,6 +562,13 @@ public void incrementBy(String key, int value) { * @param value int value by which to multiply */ public void multiply(String key, double value) { + UserProfile target = target("[UserProfile] multiply"); + if (target != this) { + if (target != null) { + target.multiply(key, value); + } + return; + } synchronized (Countly.instance()) { modifyCustomData(key, value, Op.MUL); } @@ -529,6 +581,13 @@ public void multiply(String key, double value) { * @param value int value to check for max */ public void saveMax(String key, double value) { + UserProfile target = target("[UserProfile] saveMax"); + if (target != this) { + if (target != null) { + target.saveMax(key, value); + } + return; + } synchronized (Countly.instance()) { modifyCustomData(key, value, Op.MAX); } @@ -541,6 +600,13 @@ public void saveMax(String key, double value) { * @param value int value to check for min */ public void saveMin(String key, double value) { + UserProfile target = target("[UserProfile] saveMin"); + if (target != this) { + if (target != null) { + target.saveMin(key, value); + } + return; + } synchronized (Countly.instance()) { modifyCustomData(key, value, Op.MIN); } @@ -553,6 +619,13 @@ public void saveMin(String key, double value) { * @param value String value to set */ public void setOnce(String key, Object value) { + UserProfile target = target("[UserProfile] setOnce"); + if (target != this) { + if (target != null) { + target.setOnce(key, value); + } + return; + } synchronized (Countly.instance()) { modifyCustomData(key, value, Op.SET_ONCE); } @@ -566,6 +639,13 @@ public void setOnce(String key, Object value) { * @param value String with value to add to array */ public void push(String key, Object value) { + UserProfile target = target("[UserProfile] push"); + if (target != this) { + if (target != null) { + target.push(key, value); + } + return; + } synchronized (Countly.instance()) { modifyCustomData(key, value, Op.PUSH); } @@ -579,6 +659,13 @@ public void push(String key, Object value) { * @param value String with value to add to array */ public void pushUnique(String key, Object value) { + UserProfile target = target("[UserProfile] pushUnique"); + if (target != this) { + if (target != null) { + target.pushUnique(key, value); + } + return; + } synchronized (Countly.instance()) { modifyCustomData(key, value, Op.PUSH_UNIQUE); } @@ -592,6 +679,13 @@ public void pushUnique(String key, Object value) { * @param value String with value to remove from array */ public void pull(String key, Object value) { + UserProfile target = target("[UserProfile] pull"); + if (target != this) { + if (target != null) { + target.pull(key, value); + } + return; + } synchronized (Countly.instance()) { modifyCustomData(key, value, Op.PULL); } @@ -604,6 +698,13 @@ public void pull(String key, Object value) { * @param value the value for the user property to be set. The value should be the allowed data type. */ public void setProperty(String key, Object value) { + UserProfile target = target("[UserProfile] setProperty"); + if (target != this) { + if (target != null) { + target.setProperty(key, value); + } + return; + } synchronized (Countly.instance()) { L.i("[UserProfile] Calling 'setProperty'"); @@ -621,6 +722,13 @@ public void setProperty(String key, Object value) { * @param data Map of user properties to set */ public void setProperties(Map data) { + UserProfile target = target("[UserProfile] setProperties"); + if (target != this) { + if (target != null) { + target.setProperties(data); + } + return; + } synchronized (Countly.instance()) { L.i("[UserProfile] Calling 'setProperties'"); @@ -636,6 +744,13 @@ public void setProperties(Map data) { * Send provided values to server */ public void save() { + UserProfile target = target("[UserProfile] save"); + if (target != this) { + if (target != null) { + target.save(); + } + return; + } synchronized (Countly.instance()) { L.i("[UserProfile] Calling 'save'"); saveInternal(); @@ -646,6 +761,13 @@ public void save() { * Clear queued operations / modifications */ public void clear() { + UserProfile target = target("[UserProfile] clear"); + if (target != this) { + if (target != null) { + target.clear(); + } + return; + } synchronized (Countly.instance()) { L.i("[UserProfile] Calling 'clear'"); clearInternal(); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleViews.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleViews.java index 289956e1..378c3f6c 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleViews.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleViews.java @@ -61,6 +61,9 @@ public void deviceIdChanged(String oldDeviceId, boolean withMerge) { @Override public void stop(InternalConfig config, boolean clear) { + if (config.viewIdProvider == this) { + config.viewIdProvider = SDKCore.NO_VIEW_IDS; + } viewsInterface = null; viewDataMap.clear(); if (globalViewSegmentation != null) { @@ -427,6 +430,19 @@ private void addSegmentationToViewWithIDInternal(String viewID, Map viewSegmentation) { + Views target = target("[Views] startAutoStoppedView"); + if (target != this) { + if (target == null) { + return null; + } + return target.startAutoStoppedView(viewName, viewSegmentation); + } synchronized (Countly.instance()) { L.i("[Views] Calling startAutoStoppedView [" + viewName + "] sg[" + viewSegmentation + "]"); return startViewInternal(viewName, viewSegmentation, true); @@ -465,6 +495,13 @@ public String startAutoStoppedView(@Nonnull String viewName, @Nullable Map viewSegmentation) { + Views target = target("[Views] startView"); + if (target != this) { + if (target == null) { + return null; + } + return target.startView(viewName, viewSegmentation); + } synchronized (Countly.instance()) { L.i("[Views] Calling startView vn[" + viewName + "] sg[" + viewSegmentation + "]"); return startViewInternal(viewName, viewSegmentation, false); @@ -491,6 +535,13 @@ public String startAutoStoppedView(@Nonnull String viewName, @Nullable Map - view segmentation */ public void stopViewWithName(@Nonnull String viewName, @Nullable Map viewSegmentation) { + Views target = target("[Views] stopViewWithName"); + if (target != this) { + if (target != null) { + target.stopViewWithName(viewName, viewSegmentation); + } + return; + } synchronized (Countly.instance()) { L.i("[Views] Calling stopViewWithName vn[" + viewName + "] sg[" + viewSegmentation + "]"); stopViewWithNameInternal(viewName, viewSegmentation); @@ -516,6 +574,13 @@ public void stopViewWithName(@Nonnull String viewName, @Nullable Map - view segmentation */ public void stopViewWithID(@Nonnull String viewID, @Nullable Map viewSegmentation) { + Views target = target("[Views] stopViewWithID"); + if (target != this) { + if (target != null) { + target.stopViewWithID(viewID, viewSegmentation); + } + return; + } synchronized (Countly.instance()) { L.i("[Views] Calling stopViewWithID vi[" + viewID + "] sg[" + viewSegmentation + "]"); stopViewWithIDInternal(viewID, viewSegmentation); @@ -541,6 +613,13 @@ public void stopViewWithID(@Nonnull String viewID, @Nullable Map * @param viewID String - view ID */ public void pauseViewWithID(@Nonnull String viewID) { + Views target = target("[Views] pauseViewWithID"); + if (target != this) { + if (target != null) { + target.pauseViewWithID(viewID); + } + return; + } synchronized (Countly.instance()) { L.i("[Views] Calling pauseViewWithID vi[" + viewID + "]"); pauseViewWithIDInternal(viewID); @@ -553,6 +632,13 @@ public void pauseViewWithID(@Nonnull String viewID) { * @param viewID String - view ID */ public void resumeViewWithID(@Nonnull String viewID) { + Views target = target("[Views] resumeViewWithID"); + if (target != this) { + if (target != null) { + target.resumeViewWithID(viewID); + } + return; + } synchronized (Countly.instance()) { L.i("[Views] Calling resumeViewWithID vi[" + viewID + "]"); resumeViewWithIDInternal(viewID); @@ -565,6 +651,13 @@ public void resumeViewWithID(@Nonnull String viewID) { * @param viewSegmentation Map - view segmentation */ public void stopAllViews(@Nullable Map viewSegmentation) { + Views target = target("[Views] stopAllViews"); + if (target != this) { + if (target != null) { + target.stopAllViews(viewSegmentation); + } + return; + } synchronized (Countly.instance()) { L.i("[Views] Calling stopAllViews sg[" + viewSegmentation + "]"); stopAllViewsInternal(viewSegmentation); @@ -578,6 +671,13 @@ public void stopAllViews(@Nullable Map viewSegmentation) { * @param viewSegmentation Map */ public void addSegmentationToViewWithName(@Nonnull String viewName, @Nullable Map viewSegmentation) { + Views target = target("[Views] addSegmentationToViewWithName"); + if (target != this) { + if (target != null) { + target.addSegmentationToViewWithName(viewName, viewSegmentation); + } + return; + } synchronized (Countly.instance()) { L.i("[Views] Calling addSegmentationToViewWithName vn[" + viewName + "] sg[" + viewSegmentation + "]"); addSegmentationToViewWithNameInternal(viewName, viewSegmentation); @@ -591,6 +691,13 @@ public void addSegmentationToViewWithName(@Nonnull String viewName, @Nullable Ma * @param viewSegmentation Map */ public void addSegmentationToViewWithID(@Nonnull String viewId, @Nullable Map viewSegmentation) { + Views target = target("[Views] addSegmentationToViewWithID"); + if (target != this) { + if (target != null) { + target.addSegmentationToViewWithID(viewId, viewSegmentation); + } + return; + } synchronized (Countly.instance()) { L.i("[Views] Calling addSegmentationToViewWithID vi[" + viewId + "] sg[" + viewSegmentation + "]"); addSegmentationToViewWithIDInternal(viewId, viewSegmentation); @@ -603,6 +710,13 @@ public void addSegmentationToViewWithID(@Nonnull String viewId, @Nullable Map - global view segmentation */ public void setGlobalViewSegmentation(@Nullable Map segmentation) { + Views target = target("[Views] setGlobalViewSegmentation"); + if (target != this) { + if (target != null) { + target.setGlobalViewSegmentation(segmentation); + } + return; + } synchronized (Countly.instance()) { L.i("[Views] Calling setGlobalViewSegmentation sg[" + (segmentation == null ? null : segmentation.size()) + "]"); @@ -616,6 +730,13 @@ public void setGlobalViewSegmentation(@Nullable Map segmentation * @param segmentation Map - global view segmentation */ public void updateGlobalViewSegmentation(@Nullable Map segmentation) { + Views target = target("[Views] updateGlobalViewSegmentation"); + if (target != this) { + if (target != null) { + target.updateGlobalViewSegmentation(segmentation); + } + return; + } synchronized (Countly.instance()) { L.i("[Views] Calling updateGlobalViewSegmentation sg[" + (segmentation == null ? null : segmentation.size()) + "]"); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java index 7320d6ff..7a3776b1 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java @@ -8,6 +8,8 @@ import java.util.Queue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; import javax.annotation.Nonnull; import javax.annotation.Nullable; import ly.count.sdk.java.Config; @@ -38,6 +40,54 @@ public class SDKCore { */ private static final Map> moduleMappings = new ConcurrentHashMap<>(); + /** + * The view IDs events carry while no views module runs. + */ + static final ViewIdProvider NO_VIEW_IDS = new ViewIdProvider() { + /** + * No view runs, so there is no current view ID. + */ + @Nonnull public String getCurrentViewId() { + return ""; + } + + /** + * No view runs, so there is no previous view ID. + */ + @Nonnull public String getPreviousViewId() { + return ""; + } + }; + + /** + * Interfaces of module instances the SDK never runs, handed out in place of the interface of a + * feature that does not run: before init, while its consent is not given, or when it is not + * enabled, so a caller never gets {@code null}. Like every module interface, each sends its + * calls to the module of its feature once that runs and ignores them until then. + */ + public static final ModuleEvents.Events dormantEvents = new ModuleEvents().new Events(); + public static final ModuleFeedback.Feedback dormantFeedback = new ModuleFeedback().new Feedback(); + public static final ModuleContent.Content dormantContent = new ModuleContent().new Content(); + public static final ModuleCrashes.Crashes dormantCrashes = new ModuleCrashes().new Crashes(); + public static final ModuleViews.Views dormantViews = new ModuleViews().new Views(); + public static final ModuleDeviceIdCore.DeviceId dormantDeviceId = new ModuleDeviceIdCore().new DeviceId(); + public static final ModuleRemoteConfig.RemoteConfig dormantRemoteConfig = new ModuleRemoteConfig().new RemoteConfig(); + public static final ModuleUserProfile.UserProfile dormantUserProfile = new ModuleUserProfile().new UserProfile(); + public static final ModuleLocation.Location dormantLocation = new ModuleLocation().new Location(); + + /** + * The remote config download callbacks, those of the configuration and those registered through + * the remote config interface, kept beyond the module, so one built when the consent is given, + * or given again, calls them too. + */ + final List remoteConfigDownloadCallbacks = new CopyOnWriteArrayList<>(); + + /** + * The display registered through the content interface, kept beyond the module, so one built + * when the consent is given, or given again, draws with it. + */ + volatile ContentDisplay contentDisplay = null; + // sorted by feature index, and safe to iterate while a consent change adds or removes a module protected final Map modules; @@ -96,7 +146,14 @@ public boolean isTracking(Integer feat) { } private void onTimer() { - modules.forEach((feature, module) -> module.onTimer()); + modules.forEach((feature, module) -> { + try { + module.onTimer(); + } catch (Throwable t) { + //one failing module does not hold back the others on the same tick + L.e("[SDKCore] onTimer, " + module.getClass().getSimpleName() + " failed on a tick, [" + t + "]"); + } + }); L.flushGatheredLogs(); } @@ -195,6 +252,7 @@ public void onConsent(final int consent) { } else { module.init(config); module.initFinished(config); + module.setActive(true); modules.put(feature.getIndex(), module); } } @@ -220,14 +278,20 @@ public void onConsentRemoval(final InternalConfig config, int noConsent) { } if (removingConsent(noConsent, CoreFeature.Location)) { - user().edit().optOutFromLocationServices(); + ModuleLocation location = module(ModuleLocation.class); + if (location != null) { + location.eraseLocation(); + } } consents = consents & ~noConsent; for (CoreFeature feature : moduleMappings.keySet()) { + if (feature == CoreFeature.Sessions || (noConsent & feature.getIndex()) == 0) { + continue; + } ModuleBase existing = module(moduleMappings.get(feature)); - if (feature.getIndex() != CoreFeature.Sessions.getIndex() && existing != null) { + if (existing != null) { existing.stop(config, true); modules.remove(feature.getIndex()); } @@ -450,6 +514,7 @@ public void init(final InternalConfig givenConfig) { } requestQueueMemory = new ArrayDeque<>(config.getRequestQueueMaxSize()); + remoteConfigDownloadCallbacks.addAll(config.getRemoteConfigGlobalCallbackList()); if (config.viewIdGenerator == null) { config.viewIdGenerator = Utils::safeRandomVal; @@ -460,15 +525,7 @@ public void init(final InternalConfig givenConfig) { } if (config.viewIdProvider == null) { - config.viewIdProvider = new ViewIdProvider() { - @Nonnull public String getCurrentViewId() { - return ""; - } - - @Nonnull public String getPreviousViewId() { - return ""; - } - }; + config.viewIdProvider = NO_VIEW_IDS; } // ModuleSessions is always enabled, even without consent @@ -904,113 +961,120 @@ TimedEvents timedEvents() { return ((ModuleSessions) module(CoreFeature.Sessions)).timedEvents(); } + /** + * The events interface: the running module's, or one that ignores calls until the events feature runs, as its consent is not given or it is not enabled. + * + * @return the interface, never {@code null} + */ public ModuleEvents.Events events() { - if (!hasConsentForFeature(CoreFeature.Events)) { - L.v("[SDKCore] events, Events feature has no consent, returning null"); - return null; - } - - ModuleEvents module = module(ModuleEvents.class); - if (module == null) { - return null; - } - - return module.eventsInterface; + return runningInterface(CoreFeature.Events, ModuleEvents.class, module -> module.eventsInterface, dormantEvents); } + /** + * The feedback interface: the running module's, or one that ignores calls until the feedback feature runs, as its consent is not given or it is not enabled. + * + * @return the interface, never {@code null} + */ public ModuleFeedback.Feedback feedback() { - - if (!hasConsentForFeature(CoreFeature.Feedback)) { - L.v("[SDKCore] feedback, Feedback feature has no consent, returning null"); - return null; - } - - ModuleFeedback module = module(ModuleFeedback.class); - if (module == null) { - // Consent is not the only gate: the feature may simply never have been enabled, - // in which case the module was never built and there is nothing to hand back. - L.v("[SDKCore] feedback, feedback feature was not enabled, returning null"); - return null; - } - - return module.feedbackInterface; + return runningInterface(CoreFeature.Feedback, ModuleFeedback.class, module -> module.feedbackInterface, dormantFeedback); } + /** + * The content interface: the running module's, or one that ignores calls until the content feature runs, as its consent is not given or it is not enabled. + * + * @return the interface, never {@code null} + */ public ModuleContent.Content content() { - if (!hasConsentForFeature(CoreFeature.Content)) { - L.v("[SDKCore] content, Content feature has no consent, returning null"); - return null; - } - - ModuleContent module = module(ModuleContent.class); - if (module == null) { - return null; - } - - return module.contentInterface; + return runningInterface(CoreFeature.Content, ModuleContent.class, module -> module.contentInterface, dormantContent); } + /** + * The crashes interface: the running module's, or one that ignores calls until crash reporting runs, as its consent is not given or it is not enabled. + * + * @return the interface, never {@code null} + */ public ModuleCrashes.Crashes crashes() { - if (!hasConsentForFeature(CoreFeature.CrashReporting)) { - L.v("[SDKCore] crash, Crash Reporting feature has no consent, returning null"); - return null; - } - - ModuleCrashes module = module(ModuleCrashes.class); - if (module == null) { - // Consent is not the only gate: the feature may simply never have been enabled, - // in which case the module was never built and there is nothing to hand back. - L.v("[SDKCore] crashes, crashes feature was not enabled, returning null"); - return null; - } - - return module.crashInterface; + return runningInterface(CoreFeature.CrashReporting, ModuleCrashes.class, module -> module.crashInterface, dormantCrashes); } + /** + * The views interface: the running module's, or one that ignores calls until the views feature runs, as its consent is not given or it is not enabled. + * + * @return the interface, never {@code null} + */ public ModuleViews.Views views() { - ModuleViews module = module(ModuleViews.class); - if (module == null) { - L.v("[SDKCore] views, Views feature has no consent, returning null"); - return null; - } - - return module.viewsInterface; + return runningInterface(null, ModuleViews.class, module -> module.viewsInterface, dormantViews); } + /** + * The device ID interface: the running module's, or one that ignores calls until the device ID module runs. + * + * @return the interface, never {@code null} + */ public ModuleDeviceIdCore.DeviceId deviceId() { - return module(ModuleDeviceIdCore.class).deviceIdInterface; + return runningInterface(null, ModuleDeviceIdCore.class, module -> module.deviceIdInterface, dormantDeviceId); } + /** + * The remote config interface: the running module's, or one that ignores calls until remote config runs, as its consent is not given or it is not enabled. + * + * @return the interface, never {@code null} + */ public ModuleRemoteConfig.RemoteConfig remoteConfig() { - if (!hasConsentForFeature(CoreFeature.RemoteConfig)) { - L.v("[SDKCore] remoteConfig, RemoteConfig feature has no consent, returning null"); - return null; - } - - ModuleRemoteConfig module = module(ModuleRemoteConfig.class); - if (module == null) { - // Consent is not the only gate: the feature may simply never have been enabled, - // in which case the module was never built and there is nothing to hand back. - L.v("[SDKCore] remoteConfig, remoteConfig feature was not enabled, returning null"); - return null; - } - - return module.remoteConfigInterface; + return runningInterface(CoreFeature.RemoteConfig, ModuleRemoteConfig.class, module -> module.remoteConfigInterface, dormantRemoteConfig); } + /** + * The user profile interface: the running module's, or one that ignores calls until the user profile module runs. + * + * @return the interface, never {@code null} + */ public ModuleUserProfile.UserProfile userProfile() { - return module(ModuleUserProfile.class).userProfileInterface; + return runningInterface(null, ModuleUserProfile.class, module -> module.userProfileInterface, dormantUserProfile); } + /** + * The location interface: the running module's, or one that ignores calls until the location feature runs, as its consent is not given or it is not enabled. + * + * @return the interface, never {@code null} + */ public ModuleLocation.Location location() { - if (!hasConsentForFeature(CoreFeature.Location)) { - L.v("[SDKCore] location, Location feature has no consent, returning null"); - return null; - } - ModuleLocation module = module(ModuleLocation.class); - if (module == null) { - return null; - } - return module.locationInterface; + return runningInterface(CoreFeature.Location, ModuleLocation.class, module -> module.locationInterface, dormantLocation); + } + + /** + * The interface of a running module, or the one handed out while it does not run, whose calls + * go to the module once it runs and are ignored until then. + * + * @param consent the feature whose consent the module needs, {@code null} when it needs none + * @param moduleClass the class of the module + * @param interfaceOf reads the interface of a module + * @param dormant the interface handed out while the module does not run + * @param the module type + * @param the interface type + * @return the interface, never {@code null} + */ + private I runningInterface(@Nullable CoreFeature consent, @Nonnull Class moduleClass, @Nonnull Function interfaceOf, @Nonnull I dormant) { + M module = consent == null ? module(moduleClass) : runningModule(consent, moduleClass); + I running = module == null ? null : interfaceOf.apply(module); + if (running == null) { + L.v("[SDKCore] runningInterface, " + moduleClass.getSimpleName() + " does not run, handing out an interface that ignores calls until it does"); + return dormant; + } + return running; + } + + /** + * The module of a feature while it runs: it was built and the feature's consent is given. + * + * @param feature the feature + * @param moduleClass the class of its module + * @param the module type + * @return the module, {@code null} while it does not run + */ + @Nullable + M runningModule(@Nonnull CoreFeature feature, @Nonnull Class moduleClass) { + M module = module(moduleClass); + return module != null && hasConsentForFeature(feature) ? module : null; } } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java index 547a5b61..66cc7271 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java @@ -120,9 +120,7 @@ Future begin(Long now) { } this.consents = SDKCore.instance.consents; - if (config.isAutoSendUserProperties() && config.sdk.userProfile() != null) { - config.sdk.module(ModuleUserProfile.class).saveInternal(); - } + ModuleUserProfile.saveIfSentAutomatically(config); if (pushOnChange) { Storage.pushAsync(config, this); @@ -165,9 +163,7 @@ Future update(Long now) { } this.consents = SDKCore.instance.consents; - if (config.isAutoSendUserProperties() && config.sdk.userProfile() != null) { - config.sdk.module(ModuleUserProfile.class).saveInternal(); - } + ModuleUserProfile.saveIfSentAutomatically(config); Long duration = updateDuration(now); @@ -205,9 +201,7 @@ Future end(Long now, final Tasks.Callback callback, String did ended = now == null ? System.nanoTime() : now; this.consents = SDKCore.instance.consents; - if (config.isAutoSendUserProperties() && config.sdk.userProfile() != null) { - config.sdk.module(ModuleUserProfile.class).saveInternal(); - } + ModuleUserProfile.saveIfSentAutomatically(config); if (currentView != null) { currentView.stop(true); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/UserEditorImpl.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/UserEditorImpl.java index 33e586cc..ca8eda24 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/UserEditorImpl.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/UserEditorImpl.java @@ -1,6 +1,7 @@ package ly.count.sdk.java.internal; import java.io.File; +import java.util.function.Consumer; import ly.count.sdk.java.Countly; import ly.count.sdk.java.PredefinedUserPropertyKeys; import ly.count.sdk.java.User; @@ -100,68 +101,70 @@ public UserEditor setBirthyear(String birthyear) { @Override public UserEditor setLocale(String locale) { - L.d("setLocale: locale = " + locale); + L.d("[UserEditorImpl] setLocale"); return this; } @Override public UserEditor setCountry(String country) { - L.d("setCountry: country = " + country); + L.d("[UserEditorImpl] setCountry"); String countryLegacy = country == null ? JSONObject.NULL.toString() : country; - callLocationService(() -> SDKCore.instance.module(ModuleLocation.class).setLocationLegacy(countryLegacy, null, null)); + callLocationService(module -> module.setLocationLegacy(countryLegacy, null, null)); return this; } @Override public UserEditor setCity(String city) { - L.d("setCity: city = " + city); + L.d("[UserEditorImpl] setCity"); String cityLegacy = city == null ? JSONObject.NULL.toString() : city; - callLocationService(() -> SDKCore.instance.module(ModuleLocation.class).setLocationLegacy(null, cityLegacy, null)); + callLocationService(module -> module.setLocationLegacy(null, cityLegacy, null)); return this; } @Override public UserEditor setLocation(String location) { - L.d("setLocation: location = " + location); + L.d("[UserEditorImpl] setLocation"); if (location != null) { String[] comps = location.split(","); if (comps.length == 2) { try { String locationString = Double.parseDouble(comps[0]) + "," + Double.parseDouble(comps[1]); - callLocationService(() -> SDKCore.instance.module(ModuleLocation.class).setLocationLegacy(null, null, locationString)); + callLocationService(module -> module.setLocationLegacy(null, null, locationString)); return this; } catch (Throwable t) { - L.e("[UserEditorImpl] Invalid location format: " + location + " " + t); + L.e("[UserEditorImpl] setLocation, invalid location format, " + t.getClass().getSimpleName()); return this; } } else { - L.e("[UserEditorImpl] Invalid location format: " + location); + L.e("[UserEditorImpl] setLocation, invalid location format"); return this; } } else { - callLocationService(() -> SDKCore.instance.module(ModuleLocation.class).setLocationLegacy(null, null, JSONObject.NULL)); + callLocationService(module -> module.setLocationLegacy(null, null, JSONObject.NULL)); return this; } } @Override public UserEditor setLocation(double latitude, double longitude) { - L.d("setLocation: latitude = " + latitude + " longitude" + longitude); + L.d("[UserEditorImpl] setLocation"); String locationString = latitude + "," + longitude; - callLocationService(() -> SDKCore.instance.module(ModuleLocation.class).setLocationLegacy(null, null, locationString)); + callLocationService(module -> module.setLocationLegacy(null, null, locationString)); return this; } @Override public UserEditor optOutFromLocationServices() { L.d("optOutFromLocationServices"); - callLocationService(() -> SDKCore.instance.module(ModuleLocation.class).setLocationLegacy(JSONObject.NULL, JSONObject.NULL, JSONObject.NULL)); + callLocationService(module -> module.setLocationLegacy(JSONObject.NULL, JSONObject.NULL, JSONObject.NULL)); return this; } - private void callLocationService(Runnable runnable) { - if (Countly.instance().location() != null) { - runnable.run(); + private void callLocationService(Consumer call) { + SDKCore core = SDKCore.instance; + ModuleLocation location = core == null ? null : core.runningModule(CoreFeature.Location, ModuleLocation.class); + if (location != null) { + call.accept(location); } } diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/CoreRobustnessTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/CoreRobustnessTests.java index 860fb210..653a429d 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/CoreRobustnessTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/CoreRobustnessTests.java @@ -76,36 +76,42 @@ public void init_withANullConfig_isRefusedWithoutCrashing() { } /** - * Feature accessors for features that were never enabled return null rather than throwing. + * Feature accessors for features that were never enabled hand out an interface that ignores every + * call, rather than null or throwing. *

* They dereferenced a module that was never built, and consent is not the gate that stops them: * with consent not required, the consent check passes and the null module was used anyway. */ @Test - public void accessors_forFeaturesThatWereNeverEnabled_returnNull() { + public void accessors_forFeaturesThatWereNeverEnabled_handOutInterfacesThatIgnoreCalls() { Countly.instance().init(TestUtils.getConfigEvents(2)); - Assert.assertNull(Countly.instance().feedback()); - Assert.assertNull(Countly.instance().crashes()); - Assert.assertNull(Countly.instance().remoteConfig()); - Assert.assertNull(Countly.instance().content()); + Assert.assertSame(SDKCore.dormantFeedback, Countly.instance().feedback()); + Assert.assertSame(SDKCore.dormantCrashes, Countly.instance().crashes()); + Assert.assertSame(SDKCore.dormantRemoteConfig, Countly.instance().remoteConfig()); + Assert.assertSame(SDKCore.dormantContent, Countly.instance().content()); + int queued = TestUtils.getCurrentRQ().length; + Countly.instance().crashes().recordHandledException(new RuntimeException("never enabled")); + Countly.instance().content().enterContentZone(); + Assert.assertEquals(queued, TestUtils.getCurrentRQ().length); // The feature that IS enabled still works, so the guards did not break the happy path. - Assert.assertNotNull(Countly.instance().events()); + Assert.assertNotSame(SDKCore.dormantEvents, Countly.instance().events()); } /** - * Accessors used before init log and return null. "location" logged through a logger that does - * not exist yet, unlike every sibling accessor. + * Accessors used before init log and hand out an interface that ignores calls, backend mode + * aside, which still answers null. "location" logged through a logger that does not exist yet, + * unlike every sibling accessor. */ @Test - public void accessors_beforeInit_returnNullWithoutCrashing() { + public void accessors_beforeInit_doNotThrow() { Countly.instance().halt(); - Assert.assertNull(Countly.instance().location()); - Assert.assertNull(Countly.instance().feedback()); - Assert.assertNull(Countly.instance().content()); - Assert.assertNull(Countly.instance().remoteConfig()); + Assert.assertSame(SDKCore.dormantLocation, Countly.instance().location()); + Assert.assertSame(SDKCore.dormantFeedback, Countly.instance().feedback()); + Assert.assertSame(SDKCore.dormantContent, Countly.instance().content()); + Assert.assertSame(SDKCore.dormantRemoteConfig, Countly.instance().remoteConfig()); Assert.assertNull(Countly.instance().backendM()); } diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/CountlyFacadeTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/CountlyFacadeTests.java index 57593b82..a36435d2 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/CountlyFacadeTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/CountlyFacadeTests.java @@ -1,5 +1,8 @@ package ly.count.sdk.java.internal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -21,9 +24,9 @@ /** * The static {@link Countly} facade and the {@link SDKCore} accessors behind it. *

- * Two contracts are covered: before {@code init} every accessor must answer with null and a logged - * error instead of throwing, and after {@code init} the deprecated {@link Usage} API must still route - * to the live session. Both matter because they are the surface an integration touches first. + * Two contracts are covered: before {@code init} no accessor may throw, and every feature interface + * is handed out but ignores calls, and after {@code init} the deprecated {@link Usage} API must still + * route to the live session. Both matter because they are the surface an integration touches first. */ @RunWith(JUnit4.class) public class CountlyFacadeTests { @@ -42,26 +45,41 @@ public void stop() { /** * Nothing may throw before {@code init}. Every module accessor and every state query is walked in - * one pass, so a newly added accessor that forgets its guard shows up here. + * one pass, so a newly added accessor that forgets its guard shows up here. Each feature interface + * is handed out and ignores calls, so code that calls it before init does not throw either, and a + * call with a callback gets its answer. */ @Test - public void beforeInit_everyAccessorAnswersNullInsteadOfThrowing() { + public void beforeInit_noAccessorThrows_andEveryFeatureInterfaceIgnoresCalls() { Assert.assertFalse(Countly.isInitialized()); Assert.assertNull(Countly.session()); Assert.assertNull(Countly.instance().backendM()); Assert.assertNull(Countly.backendMode()); - Assert.assertNull(Countly.instance().feedback()); - Assert.assertNull(Countly.instance().content()); - Assert.assertNull(Countly.instance().events()); - Assert.assertNull(Countly.instance().views()); - Assert.assertNull(Countly.instance().crashes()); - Assert.assertNull(Countly.instance().remoteConfig()); - Assert.assertNull(Countly.instance().deviceId()); - Assert.assertNull(Countly.instance().userProfile()); - // Countly.location() is deliberately NOT called here: its own uninitialised guard logs - // through the instance logger without a null check, so it throws NPE before it can return - // null. Reported as a bug rather than asserted, so a fix does not have to touch this test. + Assert.assertSame(SDKCore.dormantFeedback, Countly.instance().feedback()); + Assert.assertSame(SDKCore.dormantContent, Countly.instance().content()); + Assert.assertSame(SDKCore.dormantEvents, Countly.instance().events()); + Assert.assertSame(SDKCore.dormantViews, Countly.instance().views()); + Assert.assertSame(SDKCore.dormantCrashes, Countly.instance().crashes()); + Assert.assertSame(SDKCore.dormantRemoteConfig, Countly.instance().remoteConfig()); + Assert.assertSame(SDKCore.dormantDeviceId, Countly.instance().deviceId()); + Assert.assertSame(SDKCore.dormantUserProfile, Countly.instance().userProfile()); + Assert.assertSame(SDKCore.dormantLocation, Countly.instance().location()); + + Assert.assertFalse(Countly.instance().events().startEvent("before_init")); + Countly.instance().events().recordEvent("before_init"); + Assert.assertNull(Countly.instance().views().startView("home")); + Countly.instance().crashes().recordHandledException(new RuntimeException("before init")); + Assert.assertNull(Countly.instance().deviceId().getID()); + Assert.assertTrue(Countly.instance().remoteConfig().getValues().isEmpty()); + Countly.instance().userProfile().setProperty("plan", "pro"); + Countly.instance().location().setLocation("TR", "Izmir", "38.4,27.1", null); + Countly.instance().content().enterContentZone(); + List errors = new CopyOnWriteArrayList<>(); + Countly.instance().feedback().getAvailableFeedbackWidgets((widgets, error) -> errors.add(error)); + Countly.instance().remoteConfig().downloadAllKeys((result, error, fullValueUpdate, values) -> errors.add(error)); + Assert.assertEquals(2, errors.size()); + Assert.assertFalse(Countly.isInitialized()); // isTracking short circuits on the uninitialised check rather than dereferencing the SDK. for (Config.Feature feature : Config.Feature.values()) { @@ -261,11 +279,12 @@ public void stop(InternalConfig config, boolean clear) { } /** - * Consent gating of the accessors. With consent required and nothing granted the feature - * interfaces must be withheld; granting afterwards must hand them over. + * Consent gating of the accessors. With consent required and nothing granted every feature + * interface ignores calls; granting afterwards hands over the running ones, and removing the + * consent again hands out the ignoring ones. */ @Test - public void consentGating_withholdsThenReleasesTheFeatureInterfaces() { + public void consentGating_handsOutInterfacesThatIgnoreCalls_untilTheConsentIsGiven() { Config config = TestUtils.getBaseConfig() .enableFeatures(Config.Feature.Events, Config.Feature.Views, Config.Feature.Location, Config.Feature.Feedback, Config.Feature.RemoteConfig, Config.Feature.CrashReporting) @@ -273,28 +292,198 @@ public void consentGating_withholdsThenReleasesTheFeatureInterfaces() { Countly.instance().init(config); Assert.assertFalse(Countly.isTracking(Config.Feature.Events)); - Assert.assertNull(Countly.instance().events()); - Assert.assertNull(Countly.instance().views()); - Assert.assertNull(Countly.instance().location()); - Assert.assertNull(Countly.instance().feedback()); - Assert.assertNull(Countly.instance().remoteConfig()); - Assert.assertNull(Countly.instance().crashes()); + Assert.assertSame(SDKCore.dormantEvents, Countly.instance().events()); + Assert.assertSame(SDKCore.dormantViews, Countly.instance().views()); + Assert.assertSame(SDKCore.dormantLocation, Countly.instance().location()); + Assert.assertSame(SDKCore.dormantFeedback, Countly.instance().feedback()); + Assert.assertSame(SDKCore.dormantRemoteConfig, Countly.instance().remoteConfig()); + Assert.assertSame(SDKCore.dormantCrashes, Countly.instance().crashes()); + Countly.instance().events().recordEvent("before_consent"); + TestUtils.validateEQSize(0); Countly.onConsent(Config.Feature.values()); Assert.assertTrue(Countly.isTracking(Config.Feature.Events)); - Assert.assertNotNull(Countly.instance().events()); - Assert.assertNotNull(Countly.instance().views()); - Assert.assertNotNull(Countly.instance().location()); - Assert.assertNotNull(Countly.instance().feedback()); - Assert.assertNotNull(Countly.instance().remoteConfig()); - Assert.assertNotNull(Countly.instance().crashes()); + Assert.assertSame(SDKCore.instance.module(ModuleEvents.class).eventsInterface, Countly.instance().events()); + Assert.assertNotSame(SDKCore.dormantViews, Countly.instance().views()); + Assert.assertNotSame(SDKCore.dormantLocation, Countly.instance().location()); + Assert.assertNotSame(SDKCore.dormantFeedback, Countly.instance().feedback()); + Assert.assertNotSame(SDKCore.dormantRemoteConfig, Countly.instance().remoteConfig()); + Assert.assertNotSame(SDKCore.dormantCrashes, Countly.instance().crashes()); Countly.onConsentRemoval(Config.Feature.values()); Assert.assertFalse(Countly.isTracking(Config.Feature.Events)); - Assert.assertNull(Countly.instance().events()); - Assert.assertNull(Countly.instance().views()); + Assert.assertSame(SDKCore.dormantEvents, Countly.instance().events()); + Assert.assertSame(SDKCore.dormantViews, Countly.instance().views()); + } + + /** + * Every interface sends its calls to the module of its feature once that runs, wherever it came + * from, as a caller may keep the one it got first. + *

+ * Verifies that an events interface handed out before init and one handed out before the consent + * record once the consent is given, that a views interface handed out before the consent starts a + * view then, and that a running events interface kept across a removal of its consent ignores + * calls until the consent is given again and then records into the new module. + */ + @Test + public void aKeptInterface_reachesTheModuleOnceItsFeatureRuns() { + ModuleEvents.Events beforeInit = Countly.instance().events(); + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Events, Config.Feature.Views) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + ModuleEvents.Events beforeConsent = Countly.instance().events(); + ModuleViews.Views viewsBeforeConsent = Countly.instance().views(); + beforeConsent.recordEvent("ignored_before_consent"); + Assert.assertNull(viewsBeforeConsent.startView("ignored")); + TestUtils.validateEQSize(0); + + Countly.onConsent(Config.Feature.Events, Config.Feature.Views); + + beforeInit.recordEvent("from_before_init"); + beforeConsent.recordEvent("from_before_consent"); + Assert.assertNotNull(viewsBeforeConsent.startView("home")); + Assert.assertTrue(beforeConsent.startEvent("timed")); + Assert.assertTrue(Countly.instance().events().endEvent("timed")); + Assert.assertEquals(Arrays.asList("from_before_init", "from_before_consent", ModuleViews.KEY_VIEW_EVENT, "timed"), TestUtils.keysInEQ()); + + ModuleEvents.Events running = Countly.instance().events(); + Countly.onConsentRemoval(Config.Feature.Events); + running.recordEvent("ignored_after_removal"); + TestUtils.validateEQSize(0); + + Countly.onConsent(Config.Feature.Events); + running.recordEvent("after_consent_again"); + Assert.assertEquals(Collections.singletonList("after_consent_again"), TestUtils.keysInEQ()); + } + + /** + * Events carry the view IDs of the views module that runs now. + *

+ * Verifies that while the views consent is removed an event carries no current view ID, and that + * once it is given again an event carries the ID of the view the new module started, as does the + * next view as its previous one. + */ + @Test + public void viewIds_followTheViewsModuleThatRuns_acrossAViewsConsentChange() { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Events, Config.Feature.Views) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + Countly.onConsent(Config.Feature.Events, Config.Feature.Views); + Assert.assertNotNull(Countly.instance().views().startView("first")); + + Countly.onConsentRemoval(Config.Feature.Views); + Countly.instance().events().recordEvent("without_views"); + Countly.onConsent(Config.Feature.Views); + String second = Countly.instance().views().startView("second"); + Countly.instance().events().recordEvent("with_views"); + Countly.instance().views().startView("third"); + + List queued = TestUtils.getCurrentEQ(); + Assert.assertEquals(Arrays.asList(ModuleViews.KEY_VIEW_EVENT, "without_views", ModuleViews.KEY_VIEW_EVENT, "with_views", ModuleViews.KEY_VIEW_EVENT), TestUtils.keysInEQ()); + Assert.assertTrue(Utils.isEmptyOrNull(queued.get(1).cvid)); + Assert.assertEquals(second, queued.get(3).cvid); + Assert.assertEquals(second, queued.get(4).pvid); + } + + /** + * A module that fails on a timer tick is logged and the timer keeps ticking for every module. + */ + @Test + public void aModuleFailingOnATick_doesNotStopTheTimer() throws InterruptedException { + AtomicInteger ticks = new AtomicInteger(); + SDKCore.testDummyModule = new ModuleBase() { + /** + * Counts the tick and fails on the first one. + */ + @Override + public void onTimer() { + if (ticks.incrementAndGet() == 1) { + throw new IllegalStateException("failing on the first tick"); + } + } + }; + try { + Countly.instance().init(TestUtils.getBaseConfig().setUpdateSessionTimerDelay(1)); + long deadline = System.currentTimeMillis() + 10_000; + while (ticks.get() < 2 && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + Assert.assertTrue("ticks: " + ticks.get(), ticks.get() >= 2); + } finally { + SDKCore.testDummyModule = null; + } + } + + /** + * The deprecated API and the user editor log no user data before the feature it belongs to + * checks its consent, as such a line can be gathered for the server under another consent. + */ + @Test + public void deprecatedCallsAndTheUserEditor_logNoUserData_beforeTheirConsentCheck() { + List lines = new CopyOnWriteArrayList<>(); + Countly.instance().init(TestUtils.getBaseConfig().setRequiresConsent(true).setLoggingLevel(Config.LoggingLevel.VERBOSE) + .enableFeatures(Config.Feature.Sessions, Config.Feature.Location, Config.Feature.Views, Config.Feature.CrashReporting, Config.Feature.UserProfiles) + .setLogListener((message, level) -> { + if (message.startsWith("[Countly]") || message.startsWith("[UserEditorImpl]")) { + lines.add(message); + } + })); + Countly.onConsent(Config.Feature.Sessions); + Map segments = new HashMap<>(); + segments.put("secret_key", "secret_value"); + + Countly.api().addLocation(12.345678, 98.765432); + Countly.api().view("SecretViewName"); + Countly.api().addCrashReport(new RuntimeException("secret crash message"), false, "secret_crash_name", segments); + Countly.api().addParam("param_key", "secret_param_value"); + Countly.instance().user().edit().setLocale("tr_TR").setCountry("TR_country").setCity("SecretCity") + .setLocation(1.234567, 2.345678).setLocation("38.456789,27.123456").setLocation("not a location").commit(); + + Assert.assertFalse(lines.isEmpty()); + for (String line : lines) { + for (String secret : Arrays.asList("12.345678", "SecretViewName", "secret", "tr_TR", "TR_country", "SecretCity", "1.234567", "38.456789", "not a location")) { + Assert.assertFalse(line, line.contains(secret)); + } + } + } + + /** + * Removing a consent stops the modules of the features it names and no others, so the device ID + * module, which has no consent of its own, keeps running. + *

+ * Verifies that after the location consent is removed the location interface ignores calls while + * the device ID, events, views and user profile interfaces keep working and recording, and that + * an events interface kept from before the events consent is removed ignores calls instead of + * recording into the stopped module. + */ + @Test + public void consentRemoval_stopsOnlyTheFeaturesItNames_andAKeptInterfaceIgnoresCalls() { + Countly.instance().init(TestUtils.getBaseConfig() + .enableFeatures(Config.Feature.Events, Config.Feature.Views, Config.Feature.Location, Config.Feature.UserProfiles) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + Countly.onConsent(Config.Feature.values()); + ModuleEvents.Events keptEvents = Countly.instance().events(); + + Countly.onConsentRemoval(Config.Feature.Location); + + Assert.assertFalse(Countly.isTracking(Config.Feature.Location)); + Assert.assertSame(SDKCore.dormantLocation, Countly.instance().location()); + Assert.assertEquals(TestUtils.DEVICE_ID, Countly.instance().deviceId().getID()); + Assert.assertSame(keptEvents, Countly.instance().events()); + keptEvents.recordEvent("after_location_removal"); + Countly.instance().views().startView("home"); + Assert.assertEquals(2, TestUtils.getCurrentEQ().size()); + Countly.instance().userProfile().setProperty("plan", "pro"); + Countly.instance().userProfile().save(); + Assert.assertEquals(1, requestsContaining("user_details")); + + Countly.onConsentRemoval(Config.Feature.Events); + + Assert.assertSame(SDKCore.dormantEvents, Countly.instance().events()); + keptEvents.recordEvent("after_events_removal"); + Assert.assertFalse(keptEvents.startEvent("timed_after_events_removal")); + TestUtils.validateEQSize(0); + Assert.assertEquals(TestUtils.DEVICE_ID, Countly.instance().deviceId().getID()); } /** @@ -358,7 +547,7 @@ public void onRequestCompleted(Request request, String response, int responseCod Assert.assertTrue("failed: " + failures, failures.isEmpty()); Assert.assertTrue(handedOver.get() > 0); Assert.assertEquals(handedOver.get(), delivered.get()); - Assert.assertNull(Countly.instance().feedback()); + Assert.assertSame(SDKCore.dormantFeedback, Countly.instance().feedback()); } finally { SDKCore.testDummyModule = null; } diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/CrashFlowTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/CrashFlowTests.java index 2b488c34..7a0f0cc6 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/CrashFlowTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/CrashFlowTests.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import ly.count.sdk.java.Config; import ly.count.sdk.java.Countly; import ly.count.sdk.java.Crash; @@ -138,8 +139,36 @@ public void crashReporting_refusesBadInputAndStandsDownUnderBackendMode() { } /** - * Consent gating. With consent required and CrashReporting not granted the interface is withheld - * entirely; once granted the crash flows. + * With consent required, an unhandled crash is recorded once the crash reporting consent is given + * after init, and still reaches the handler the application installed. + */ + @Test + public void unhandledCrash_isRecorded_whenTheConsentIsGivenAfterInit() throws InterruptedException { + Thread.UncaughtExceptionHandler before = Thread.getDefaultUncaughtExceptionHandler(); + List reachedTheApp = new CopyOnWriteArrayList<>(); + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> reachedTheApp.add(throwable)); + try { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting).setRequiresConsent(true)); + Countly.onConsent(Config.Feature.CrashReporting); + + Thread crashing = new Thread(() -> { + throw new IllegalStateException("unhandled after consent"); + }); + crashing.start(); + crashing.join(); + Storage.await(SDKCore.instance.config.getLogger()); + + Assert.assertEquals(1, reachedTheApp.size()); + Assert.assertEquals(1, crashRequestCount()); + } finally { + Countly.instance().halt(); + Thread.setDefaultUncaughtExceptionHandler(before); + } + } + + /** + * Consent gating. With consent required and CrashReporting not granted the interface ignores every + * call; once granted the crash flows. */ @Test public void crashReporting_isGatedOnConsent() { @@ -148,7 +177,8 @@ public void crashReporting_isGatedOnConsent() { .setRequiresConsent(true); Countly.instance().init(config); - Assert.assertNull("no consent means no crash interface", Countly.instance().crashes()); + Assert.assertSame("no consent means a crash interface that ignores calls", SDKCore.dormantCrashes, Countly.instance().crashes()); + Countly.instance().crashes().recordHandledException(new RuntimeException("before consent")); Countly.onConsent(Config.Feature.CrashReporting); Assert.assertNotNull(Countly.instance().crashes()); diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationFiltersTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationFiltersTests.java index fe54f697..ba7773d1 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationFiltersTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationFiltersTests.java @@ -63,31 +63,32 @@ public void afterTest() { * {@code eb} and then {@code ew} decide which custom events are recorded, while internal events * always pass. *

- * Verifies that with a blacklist, listed keys, spaces and dashes included, are dropped with one - * warning each, timed events too, while an unlisted key, a view and an internal event listed in - * the blacklist are recorded; that a response bringing a whitelist replaces the blacklist, so only + * Verifies that with a blacklist, listed keys, spaces and dashes and a {@code [CLY]_} key the SDK + * does not record itself included, are dropped with one warning each, timed events too, while an + * unlisted key, a view and an internal event listed in the blacklist are recorded; that a response bringing a whitelist replaces the blacklist, so only * its keys are recorded, a view still is and a key the blacklist dropped is dropped again; and that * an empty whitelist allows every key. */ @Test public void eventFilter_blacklistThenWhitelist_decideCustomEventsOnly_andAnEmptyListAllowsEverything() { server.respondWith(new ServerConfigBuilder() - .eventFilterList(names("blocked_event", "event with spaces", "event-with-dashes", ModuleViews.KEY_VIEW_EVENT, "[CLY]_star_rating"), false) + .eventFilterList(names("blocked_event", "event with spaces", "event-with-dashes", "[CLY]_not_an_sdk_event", ModuleViews.KEY_VIEW_EVENT, "[CLY]_star_rating"), false) .networking(false)); init(configWithWarnings().enableFeatures(Config.Feature.Events, Config.Feature.Views).setEventQueueSizeToSend(100)); Countly.instance().events().recordEvent("blocked_event"); Countly.instance().events().recordEvent("event with spaces"); Countly.instance().events().recordEvent("event-with-dashes", TestUtils.map("colour", "red")); + Countly.instance().events().recordEvent("[CLY]_not_an_sdk_event"); Countly.instance().events().startEvent("blocked_event"); Countly.instance().events().endEvent("blocked_event"); TestUtils.validateEQSize(0); - Assert.assertEquals(4, warningsContaining("is filtered out by the event filter")); + Assert.assertEquals(5, warningsContaining("is filtered out by the event filter")); Countly.instance().events().recordEvent("allowed_event"); Countly.instance().views().startView("home"); Countly.instance().events().recordEvent("[CLY]_star_rating"); - Assert.assertEquals(Arrays.asList("allowed_event", ModuleViews.KEY_VIEW_EVENT, "[CLY]_star_rating"), keysInEQ()); + Assert.assertEquals(Arrays.asList("allowed_event", ModuleViews.KEY_VIEW_EVENT, "[CLY]_star_rating"), TestUtils.keysInEQ()); push(new ServerConfigBuilder().eventFilterList(names("allowed_event", "another_allowed"), true)); Countly.instance().events().recordEvent("allowed_event"); @@ -96,15 +97,15 @@ public void eventFilter_blacklistThenWhitelist_decideCustomEventsOnly_andAnEmpty Countly.instance().events().recordEvent("blocked_event"); Countly.instance().views().stopViewWithName("home"); Assert.assertEquals(Arrays.asList("allowed_event", ModuleViews.KEY_VIEW_EVENT, "[CLY]_star_rating", - "allowed_event", "another_allowed", ModuleViews.KEY_VIEW_EVENT), keysInEQ()); - Assert.assertEquals(6, warningsContaining("is filtered out by the event filter")); + "allowed_event", "another_allowed", ModuleViews.KEY_VIEW_EVENT), TestUtils.keysInEQ()); + Assert.assertEquals(7, warningsContaining("is filtered out by the event filter")); push(new ServerConfigBuilder().eventFilterList(names(), true)); Countly.instance().events().recordEvent("not_in_whitelist"); Countly.instance().events().recordEvent("blocked_event"); - List keys = keysInEQ(); + List keys = TestUtils.keysInEQ(); Assert.assertEquals(Arrays.asList("not_in_whitelist", "blocked_event"), keys.subList(6, keys.size())); - Assert.assertEquals(6, warningsContaining("is filtered out by the event filter")); + Assert.assertEquals(7, warningsContaining("is filtered out by the event filter")); Assert.assertEquals(0, TestUtils.getCurrentRQ().length); } @@ -372,19 +373,6 @@ private int warningsContaining(String text) { return count; } - /** - * The keys of the events in the event queue, in queue order. - * - * @return the keys - */ - private static List keysInEQ() { - List keys = new ArrayList<>(); - for (EventImpl event : TestUtils.getCurrentEQ()) { - keys.add(event.key); - } - return keys; - } - /** * Every event of every stored request, in queue order. * diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationTests.java index 8a7b31c6..66be8118 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationTests.java @@ -379,8 +379,10 @@ public void backendMode_keepsTheSettingsInert() { *

* Verifies that a {@code cr} arriving while the SDK runs is stored but leaves the running SDK * without consent gating, that the stored value is in place before the modules are built on the - * next init, so the feature modules wait for consent, and that removing consent stops feature - * modules but never the configuration module, which keeps applying responses. + * next init, so the feature modules wait for consent while an integration that never asked for + * consent calls an events interface that ignores calls instead of throwing, and that removing + * consent stops feature modules but never the configuration module, which keeps applying + * responses. */ @Test public void consentRequirement_appliesAtInitOnly_andConsentRemovalNeverStopsTheConfigurationModule() { @@ -399,7 +401,9 @@ public void consentRequirement_appliesAtInitOnly_andConsentRemovalNeverStopsTheC Assert.assertTrue(SDKCore.instance.config.requiresConsent()); Assert.assertFalse(Countly.isTracking(Config.Feature.Events)); - Assert.assertNull(Countly.instance().events()); + Assert.assertSame(SDKCore.dormantEvents, Countly.instance().events()); + Countly.instance().events().recordEvent("before_consent"); + TestUtils.validateEQSize(0); Countly.onConsent(Config.Feature.Events); Assert.assertTrue(Countly.isTracking(Config.Feature.Events)); @@ -479,7 +483,8 @@ public void timers_followTheResolvedIntervals_andChangesAreDispatchedOnce() thro /** * {@code cet} covers custom events only, {@code tracking} covers every event. *

- * Verifies on the wire that with custom event tracking off a custom event is dropped while a view + * Verifies on the wire that with custom event tracking off a custom event, a {@code [CLY]_} key + * the SDK does not record itself included, is dropped while a view * started through the views interface and an internal event still reach the queue, flushed at the * event queue size the settings resolved, that tracking off then drops internal events too, and * that custom events are recorded again once both are back on. Networking is off throughout, so @@ -491,6 +496,7 @@ public void customEventTracking_off_dropsCustomEventsOnly_whileTrackingGatesEver init(TestUtils.getConfigSdkBehaviorSettings().enableFeatures(Config.Feature.Events, Config.Feature.Views).setEventQueueSizeToSend(100)); Countly.instance().events().recordEvent("custom_event"); + Countly.instance().events().recordEvent("[CLY]_not_an_sdk_event"); TestUtils.validateEQSize(0); Countly.instance().views().startView("home"); diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleContentTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleContentTests.java index 1691226a..81639399 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleContentTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleContentTests.java @@ -129,8 +129,9 @@ public void enterContentZone_withoutADisplay_isIgnored() throws JSONException { } /** - * With consent required, the content interface is unreachable until content consent is given, - * and a zone that is already running is torn down when that consent is taken away again. + * With consent required, the content interface ignores calls until content consent is given, and + * a zone that is already running is torn down when that consent is taken away again, after which + * the interface kept from before ignores calls too. */ @Test public void content_isGatedByConsent() throws JSONException { @@ -138,7 +139,7 @@ public void content_isGatedByConsent() throws JSONException { init(config); nextResponse = new JSONObject(CONTENT_RESPONSE); - Assert.assertNull(Countly.instance().content()); + Assert.assertSame(SDKCore.dormantContent, Countly.instance().content()); Countly.onConsent(Config.Feature.Content, Config.Feature.Events); installRequestMaker(); @@ -151,7 +152,9 @@ public void content_isGatedByConsent() throws JSONException { Assert.assertEquals(1, requests.size()); Countly.onConsentRemoval(Config.Feature.Content); - Assert.assertNull(Countly.instance().content()); + Assert.assertSame(SDKCore.dormantContent, Countly.instance().content()); + content.enterContentZone(); + content.refreshContentZone(); Assert.assertEquals(1, requests.size()); } diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleLocationsTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleLocationsTests.java index 6784b085..139c5671 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleLocationsTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleLocationsTests.java @@ -35,6 +35,21 @@ public void disableLocation() { validateLocationRequestInRQ(TestUtils.map("location", ""), 0); } + /** + * Removing the location consent erases the location on the server right away + * Request queue should hold the location request and then one with an empty location + */ + @Test + public void locationConsentRemoval_erasesTheLocation() { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Location).setRequiresConsent(true)); + Countly.onConsent(Config.Feature.Location); + Countly.instance().location().setLocation("TR", "Izmir", "38.4,27.1", null); + + Countly.onConsentRemoval(Config.Feature.Location); + + validateLocationRequestInRQ(TestUtils.map("location", ""), 1); + } + /** * "disableLocation" * Validating that empty location parameter is not sent because location is not enabled @@ -43,7 +58,8 @@ public void disableLocation() { @Test public void disableLocation_noConsent() { Countly.instance().init(TestUtils.getBaseConfig()); - Assert.assertNull(Countly.instance().location()); + Assert.assertSame(SDKCore.dormantLocation, Countly.instance().location()); + Countly.instance().location().disableLocation(); Assert.assertEquals(0, TestUtils.getCurrentRQ().length); } diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleRemoteConfigTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleRemoteConfigTests.java index 346a8038..0f24654d 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleRemoteConfigTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleRemoteConfigTests.java @@ -1,7 +1,11 @@ package ly.count.sdk.java.internal; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Map; +import ly.count.sdk.java.Config; import ly.count.sdk.java.Countly; import org.json.JSONArray; import org.json.JSONObject; @@ -134,11 +138,10 @@ public void getValue_nullKey() { @Test public void registerDownloadCallback_null() { Countly.instance().init(TestUtils.getConfigRemoteConfigs()); - ModuleRemoteConfig rcModule = SDKCore.instance.module(ModuleRemoteConfig.class); - Assert.assertEquals(0, rcModule.downloadCallbacks.size()); + Assert.assertEquals(0, SDKCore.instance.remoteConfigDownloadCallbacks.size()); Countly.instance().remoteConfig().registerDownloadCallback(null); - Assert.assertEquals(0, rcModule.downloadCallbacks.size()); + Assert.assertEquals(0, SDKCore.instance.remoteConfigDownloadCallbacks.size()); } /** @@ -149,12 +152,32 @@ public void registerDownloadCallback_null() { @Test public void registerDownloadCallback() { Countly.instance().init(TestUtils.getConfigRemoteConfigs()); - ModuleRemoteConfig rcModule = SDKCore.instance.module(ModuleRemoteConfig.class); - Assert.assertEquals(0, rcModule.downloadCallbacks.size()); + Assert.assertEquals(0, SDKCore.instance.remoteConfigDownloadCallbacks.size()); Countly.instance().remoteConfig().registerDownloadCallback((rResult, error, fullValueUpdate, downloadedValues) -> { }); - Assert.assertEquals(1, rcModule.downloadCallbacks.size()); + Assert.assertEquals(1, SDKCore.instance.remoteConfigDownloadCallbacks.size()); + } + + /** + * "registerDownloadCallback" before the consent + * Validating that a callback registered while remote config does not run is called by the module + * started once the consent is given, and that one removed meanwhile is not + */ + @Test + public void registerDownloadCallback_beforeTheConsent_isCalledOnceRemoteConfigRuns() { + Countly.instance().init(TestUtils.getConfigRemoteConfigs().setRequiresConsent(true)); + List results = new ArrayList<>(); + RCDownloadCallback removed = (rResult, error, fullValueUpdate, downloadedValues) -> results.add(null); + Countly.instance().remoteConfig().registerDownloadCallback((rResult, error, fullValueUpdate, downloadedValues) -> results.add(rResult)); + Countly.instance().remoteConfig().registerDownloadCallback(removed); + Countly.instance().remoteConfig().removeDownloadCallback(removed); + + Countly.onConsent(Config.Feature.RemoteConfig); + SDKCore.instance.config.immediateRequestGenerator = () -> remoteConfigRequestMaker(new JSONObject(), null, null, null); + Countly.instance().remoteConfig().downloadAllKeys(null); + + Assert.assertEquals(Collections.singletonList(RequestResult.Success), results); } /** @@ -165,11 +188,10 @@ public void registerDownloadCallback() { @Test public void removeDownloadCallback_null() { Countly.instance().init(TestUtils.getConfigRemoteConfigs()); - ModuleRemoteConfig rcModule = SDKCore.instance.module(ModuleRemoteConfig.class); - Assert.assertEquals(0, rcModule.downloadCallbacks.size()); + Assert.assertEquals(0, SDKCore.instance.remoteConfigDownloadCallbacks.size()); Countly.instance().remoteConfig().removeDownloadCallback(null); - Assert.assertEquals(0, rcModule.downloadCallbacks.size()); + Assert.assertEquals(0, SDKCore.instance.remoteConfigDownloadCallbacks.size()); } /** @@ -181,11 +203,10 @@ public void removeDownloadCallback_null() { public void removeDownloadCallback() { RCDownloadCallback callback = (rResult, error, fullValueUpdate, downloadedValues) -> Assert.assertEquals(RequestResult.Success, rResult); Countly.instance().init(TestUtils.getConfigRemoteConfigs().remoteConfigRegisterGlobalCallback(callback)); - ModuleRemoteConfig rcModule = SDKCore.instance.module(ModuleRemoteConfig.class); - Assert.assertEquals(1, rcModule.downloadCallbacks.size()); + Assert.assertEquals(1, SDKCore.instance.remoteConfigDownloadCallbacks.size()); Countly.instance().remoteConfig().removeDownloadCallback(callback); - Assert.assertEquals(0, rcModule.downloadCallbacks.size()); + Assert.assertEquals(0, SDKCore.instance.remoteConfigDownloadCallbacks.size()); } /** diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java index 356f935d..e4373e56 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java @@ -450,6 +450,19 @@ static void validateEQSize(int expectedSize) { Assert.assertEquals(expectedSize, getCurrentEQ().size()); } + /** + * The keys of the events in the event queue, in queue order. + * + * @return the keys + */ + static List keysInEQ() { + List keys = new ArrayList<>(); + for (EventImpl event : getCurrentEQ()) { + keys.add(event.key); + } + return keys; + } + static String getOS() { return System.getProperty("os.name"); }