From 48cabf1fdc1143f9ea3832482494604a6d1e9c8f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 2 Nov 2026 18:50:47 +0300 Subject: [PATCH] feat: record views and feedback results under their own consent --- CHANGELOG.md | 1 + .../count/sdk/java/internal/EventQueue.java | 38 ++ .../count/sdk/java/internal/ModuleBase.java | 20 +- .../count/sdk/java/internal/ModuleEvents.java | 86 ++- .../sdk/java/internal/ModuleFeedback.java | 7 +- .../sdk/java/internal/ModuleUserProfile.java | 2 +- .../ly/count/sdk/java/internal/SDKCore.java | 27 +- .../count/sdk/java/internal/TimedEvents.java | 2 +- .../sdk/java/internal/CountlyFacadeTests.java | 9 +- .../sdk/java/internal/EventQueueTests.java | 33 + .../ScenarioInternalEventConsentTests.java | 642 ++++++++++++++++++ .../sdk/java/internal/ViewImplTests.java | 15 +- 12 files changed, 847 insertions(+), 35 deletions(-) create mode 100644 sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInternalEventConsentTests.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f6f15d0f..5b8bd1114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ * ! 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. +* ! Minor breaking change ! Views and feedback widget results are now recorded without "Config.Feature.Events" and its consent: their own feature and its consent are enough. Before, they were dropped while the events feature did not run. Removing the events consent still drops the custom and timed events not sent yet, and now keeps the views and feedback widget results whose own consent is still given. * 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 diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/EventQueue.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/EventQueue.java index 7e6cfae42..dfef87314 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/EventQueue.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/EventQueue.java @@ -2,7 +2,9 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Iterator; import java.util.List; +import java.util.function.Predicate; import javax.annotation.Nonnull; public class EventQueue { @@ -108,4 +110,40 @@ public void clear() { eventQueueMemoryCache.clear(); } } + + /** + * Removes the events that match and stores the ones left. + * + * @param filter which events to remove + * @return the removed events, in queue order + */ + @Nonnull List take(@Nonnull Predicate filter) { + List taken = new ArrayList<>(); + synchronized (lockEQ) { + Iterator events = eventQueueMemoryCache.iterator(); + while (events.hasNext()) { + EventImpl event = events.next(); + if (filter.test(event)) { + taken.add(event); + events.remove(); + } + } + if (!taken.isEmpty()) { + SDKCore.instance.sdkStorage.storeEventQueue(joinEvents(eventQueueMemoryCache)); + } + } + return taken; + } + + /** + * Counts the queued events that match. + * + * @param filter which events to count + * @return the number of matching events + */ + int count(@Nonnull Predicate filter) { + synchronized (lockEQ) { + return (int) eventQueueMemoryCache.stream().filter(filter).count(); + } + } } 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 a6c786cbf..db46c02b4 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 @@ -52,21 +52,35 @@ public void init(InternalConfig config) { public void stop(InternalConfig config, boolean clear) { } + /** + * Whether the feature of this module runs while the SDK runs the module. The SDK runs a module + * only for its feature, so this is {@code true}, except for a module it also runs for other + * features. + * + * @return {@code true} while the feature of this module runs + */ + boolean featureRuns() { + return true; + } + /** * 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. + * started or kept from an instance that stopped. A call while none runs or its feature does not, + * 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 + * @return the running instance, {@code null} while none runs or its feature does not */ @SuppressWarnings("unchecked") @Nullable M runningInstance(@Nonnull String call) { SDKCore core = SDKCore.instance; ModuleBase running = core == null ? null : core.module(getClass()); + if (running != null && !running.featureRuns()) { + running = null; + } 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"); } 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 9bcc528a2..0c0607034 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 @@ -5,6 +5,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -17,8 +18,8 @@ public class ModuleEvents extends ModuleBase { /** * 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. + * behavior settings do not apply to, and which the views and feedback features record without + * the events consent. 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))); @@ -148,7 +149,56 @@ public void stop(InternalConfig config, final boolean clear) { } /** - * Moves every queued event into one request. + * The events feature runs while it is enabled and, when consent is required, its consent is + * given. This module also runs without it, for the events of the views and feedback features. + * + * @return {@code true} while custom events can be recorded and sent + */ + @Override + boolean featureRuns() { + return SDKCore.enabled(CoreFeature.Events); + } + + /** + * Drops the timed events and the queued events whose feature no longer runs, as the events + * consent was removed: the custom events, and the views and feedback widget results whose own + * consent is not given either. The next custom event starts a new chain of previous event IDs. + */ + synchronized void dropEventsWithoutConsent() { + List dropped = eventQueue.take(event -> !SDKCore.enabled(featureOf(event.key))); + L.d("[ModuleEvents] dropEventsWithoutConsent, the events consent was removed, dropped [" + dropped.size() + "] queued events and [" + timedEvents.size() + "] timed events"); + timedEvents.clear(); + previousEventId = null; + } + + /** + * Whether a key is the key of a custom event rather than of one the SDK records for the views or + * feedback feature, as only custom events need the events consent. + * + * @param key the key of the event + * @return {@code true} for a custom event + */ + private static boolean isCustomEventKey(@Nonnull String key) { + return !INTERNAL_EVENT_KEYS.contains(key); + } + + /** + * The feature whose consent an event needs: the views or the feedback feature for the events the + * SDK records for them, the events feature for custom events. + * + * @param key the key of the event + * @return the feature + */ + private static CoreFeature featureOf(@Nonnull String key) { + if (ModuleViews.KEY_VIEW_EVENT.equals(key)) { + return CoreFeature.Views; + } + return isCustomEventKey(key) ? CoreFeature.Events : CoreFeature.Feedback; + } + + /** + * Moves the queued events into one request: all of them while the events feature runs, otherwise + * the ones of the views and feedback features. * * @param deviceId the device ID the events belong to, {@code null} for the current one */ @@ -157,7 +207,8 @@ private void addEventsToRequestQ(@Nullable String deviceId) { } /** - * Moves every queued event into one request. + * Moves the queued events into one request: all of them while the events feature runs, otherwise + * the ones of the views and feedback features. * * @param deviceId the device ID the events belong to, {@code null} for the current one * @param journeyTrigger whether the events include a journey trigger, whose request refreshes @@ -166,8 +217,11 @@ private void addEventsToRequestQ(@Nullable String deviceId) { private synchronized void addEventsToRequestQ(@Nullable String deviceId, boolean journeyTrigger) { L.d("[ModuleEvents] addEventsToRequestQ"); - if (eventQueue.getEQ().isEmpty()) { - L.d("[ModuleEvents] addEventsToRequestQ, eventQueueMemoryCache is empty, skipping"); + boolean allEvents = featureRuns(); + //custom events restored from an earlier run wait until the events feature runs, the others never need it + List events = eventQueue.take(event -> allEvents || !isCustomEventKey(event.key)); + if (events.isEmpty()) { + L.d("[ModuleEvents] addEventsToRequestQ, no events to send, skipping"); return; } @@ -175,10 +229,9 @@ private synchronized void addEventsToRequestQ(@Nullable String deviceId, boolean if (deviceId != null) { request.params.add("device_id", deviceId); } - request.params.arr("events").put(eventQueue.getEQ()).add(); + request.params.arr("events").put(events).add(); request.own(ModuleEvents.class); - eventQueue.clear(); if (!journeyTrigger) { ModuleRequests.pushAsync(internalConfig, request); return; @@ -211,8 +264,13 @@ protected void recordEventInternal(String key, int count, Double sum, Double dur return; } - boolean isCustomEvent = !INTERNAL_EVENT_KEYS.contains(key); + boolean isCustomEvent = isCustomEventKey(key); if (isCustomEvent) { + if (!featureRuns()) { + L.d("[ModuleEvents] recordEventInternal, the events feature does not run, as its consent is not given or it is not enabled, event [" + key + "] will not be recorded"); + return; + } + if (!configProvider.getCustomEventTrackingEnabled()) { L.d("[ModuleEvents] recordEventInternal, custom event tracking is disabled by the SDK behavior settings, event [" + key + "] will not be recorded"); return; @@ -328,7 +386,8 @@ void checkEventQueueToSend(boolean forceSend) { } /** - * Sends the queued events when asked to or when the queue reached its threshold. + * Sends the queued events when asked to or when the ones the next request carries reached the + * threshold, so custom events waiting for the events feature never make the others go one by one. * * @param forceSend whether to send whatever is queued * @param journeyTrigger whether the queue holds a journey trigger @@ -338,8 +397,11 @@ private void checkEventQueueToSend(boolean forceSend, boolean journeyTrigger) { L.d("[ModuleEvents] checkEventQueueToSend, the events of the previous device ID are being ended, they are sent together under that ID"); return; } - L.d("[ModuleEvents] queue size:[" + eventQueue.eqSize() + "] || forceSend: " + forceSend); - if (forceSend || eventQueue.eqSize() >= internalConfig.getConfigurationProvider().getEventQueueSizeThreshold()) { + int queued = eventQueue.eqSize(); + L.d("[ModuleEvents] queue size:[" + queued + "] || forceSend: " + forceSend); + int threshold = internalConfig.getConfigurationProvider().getEventQueueSizeThreshold(); + boolean thresholdReached = queued >= threshold && (featureRuns() || eventQueue.count(event -> !isCustomEventKey(event.key)) >= threshold); + if (forceSend || thresholdReached) { addEventsToRequestQ(null, journeyTrigger); } } 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 f6702c0b4..e0522881c 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 @@ -267,7 +267,12 @@ private void reportFeedbackWidgetManuallyInternal(CountlyFeedbackWidget widgetIn segm.putAll(UtilsInternalLimits.truncateStringValues(widgetResult, maxValueSize, L, "[ModuleFeedback] reportFeedbackWidgetManuallyInternal")); } - Countly.instance().events().recordEvent(widgetInfo.type.eventKey, segm); + ModuleEvents events = internalConfig.sdk.module(ModuleEvents.class); + if (events == null) { + L.e("[ModuleFeedback] reportFeedbackWidgetManuallyInternal, events module is not initialized"); + return; + } + events.recordEventInternal(widgetInfo.type.eventKey, 1, null, null, segm, null); } private void putIfNotNull(Map segmentation, String key, Object value) { 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 796b5c63b..be75c3c46 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 @@ -454,7 +454,7 @@ protected void saveInternal() { return; } - ModuleEvents events = internalConfig.sdk.runningModule(CoreFeature.Events, ModuleEvents.class); + ModuleEvents events = internalConfig.sdk.module(ModuleEvents.class); if (internalConfig.isAutoSendUserProperties() && events != null) { events.checkEventQueueToSend(true); } 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 88ecd0767..de1fc12b1 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 @@ -142,7 +142,8 @@ protected static void registerDefaultModuleMappings() { * @return {@code true} if consent has been given */ public boolean isTracking(Integer feat) { - return modules.containsKey(feat); + ModuleBase module = modules.get(feat); + return module != null && module.featureRuns(); } private void onTimer() { @@ -285,11 +286,20 @@ public void onConsentRemoval(final InternalConfig config, int noConsent) { } } + boolean removingEventsConsent = removingConsent(noConsent, CoreFeature.Events); consents = consents & ~noConsent; L.dropLinesWithoutConsent(); + if (removingEventsConsent) { + ModuleEvents events = module(ModuleEvents.class); + if (events != null) { + events.dropEventsWithoutConsent(); + } + } + for (CoreFeature feature : moduleMappings.keySet()) { - if (feature == CoreFeature.Sessions || (noConsent & feature.getIndex()) == 0) { + //the events module keeps running, for the events of the views and feedback features + if (feature == CoreFeature.Sessions || feature == CoreFeature.Events || (noConsent & feature.getIndex()) == 0) { continue; } ModuleBase existing = module(moduleMappings.get(feature)); @@ -345,6 +355,8 @@ protected void buildModules(InternalConfig config, int features) throws IllegalA modules.put(-1, moduleConfiguration); } modules.put(0, new ModuleConnectionTest()); + //the events module queues the events of the views and feedback features too, which need only their own consent + modules.put(CoreFeature.Events.getIndex(), new ModuleEvents()); modules.put(CoreFeature.Sessions.getIndex(), new ModuleSessions()); modules.put(CoreFeature.UserProfiles.getIndex(), new ModuleUserProfile()); @@ -850,8 +862,10 @@ public static File sdkStorageDirectory() { } public static boolean enabled(int feature) { - return (feature & instance.consents) == feature && - (feature & instance.config().getFeatures1()) == feature; + SDKCore core = instance; + InternalConfig config = core == null ? null : core.config; + return config != null && (feature & core.consents) == feature && + (feature & config.getFeatures1()) == feature; } public static boolean enabled(CoreFeature feature) { @@ -1089,7 +1103,8 @@ private I runningInterface(@Nullable CoreFeature conse } /** - * The module of a feature while it runs: it was built and the feature's consent is given. + * The module of a feature while it runs: it was built, the feature is enabled and, when consent + * is required, the feature's consent is given. * * @param feature the feature * @param moduleClass the class of its module @@ -1099,6 +1114,6 @@ private I runningInterface(@Nullable CoreFeature conse @Nullable M runningModule(@Nonnull CoreFeature feature, @Nonnull Class moduleClass) { M module = module(moduleClass); - return module != null && hasConsentForFeature(feature) ? module : null; + return module != null && enabled(feature) ? module : null; } } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/TimedEvents.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/TimedEvents.java index 822b4f5f1..c74957e7c 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/TimedEvents.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/TimedEvents.java @@ -125,7 +125,7 @@ public boolean restore(byte[] data, Log L) { while (l-- > 0) { String key = stream.readUTF(); EventImpl event = EventImpl.fromJSON(stream.readUTF(), this, L); - ModuleEvents eventsModule = SDKCore.instance.module(ModuleEvents.class); + ModuleEvents eventsModule = SDKCore.instance.runningModule(CoreFeature.Events, ModuleEvents.class); if (eventsModule != null) { eventsModule.timedEvents.put(key, event); } else { 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 be8f9dd28..8fe66d666 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 @@ -324,7 +324,8 @@ public void consentGating_handsOutInterfacesThatIgnoreCalls_untilTheConsentIsGiv * 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. + * calls until the consent is given again and then records, while the removal dropped the custom + * events queued and kept the view, which needs only the views consent. */ @Test public void aKeptInterface_reachesTheModuleOnceItsFeatureRuns() { @@ -349,11 +350,11 @@ public void aKeptInterface_reachesTheModuleOnceItsFeatureRuns() { ModuleEvents.Events running = Countly.instance().events(); Countly.onConsentRemoval(Config.Feature.Events); running.recordEvent("ignored_after_removal"); - TestUtils.validateEQSize(0); + Assert.assertEquals(Collections.singletonList(ModuleViews.KEY_VIEW_EVENT), TestUtils.keysInEQ()); Countly.onConsent(Config.Feature.Events); running.recordEvent("after_consent_again"); - Assert.assertEquals(Collections.singletonList("after_consent_again"), TestUtils.keysInEQ()); + Assert.assertEquals(Arrays.asList(ModuleViews.KEY_VIEW_EVENT, "after_consent_again"), TestUtils.keysInEQ()); } /** @@ -453,7 +454,7 @@ public void deprecatedCallsAndTheUserEditor_logNoUserData_beforeTheirConsentChec * 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. + * recording. */ @Test public void consentRemoval_stopsOnlyTheFeaturesItNames_andAKeptInterfaceIgnoresCalls() { diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/EventQueueTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/EventQueueTests.java index dd33ba117..a5c2e9c4c 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/EventQueueTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/EventQueueTests.java @@ -5,8 +5,11 @@ import java.io.IOException; import java.nio.file.Files; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Predicate; import ly.count.sdk.java.Config; import ly.count.sdk.java.Countly; import org.junit.After; @@ -234,6 +237,36 @@ public void restoreFromDisk_corruptedData() throws IOException { validateEvent(eventQueue.eventQueueMemoryCache.get(0), "test-joinEvents-1", null, 1, null, null); } + /** + * Take the matching events from the queue + * "take" function should remove them from both memory and storage and return them in queue order + * the other events stay, "count" counts the matching ones, and a take that matches nothing changes nothing + */ + @Test + public void take() { + init(TestUtils.getConfigEvents(10)); + + EventImpl first = createEvent("test-take-1", null, 1, null, null); + EventImpl kept = createEvent("test-take-kept", null, 1, null, null); + EventImpl second = createEvent("test-take-2", null, 1, null, null); + eventQueue.addEvent(first); + eventQueue.addEvent(kept); + eventQueue.addEvent(second); + Predicate notKept = event -> !"test-take-kept".equals(event.key); + Assert.assertEquals(2, eventQueue.count(notKept)); + + Assert.assertEquals(Arrays.asList(first, second), eventQueue.take(notKept)); + + Assert.assertEquals(Collections.singletonList(kept), eventQueue.eventQueueMemoryCache); + Assert.assertEquals(Collections.singletonList("test-take-kept"), TestUtils.keysInEQ()); + Assert.assertEquals(0, eventQueue.count(notKept)); + Assert.assertTrue(eventQueue.take(notKept).isEmpty()); + TestUtils.validateEQSize(1, eventQueue); + + Assert.assertEquals(Collections.singletonList(kept), eventQueue.take(event -> true)); + TestUtils.validateEQSize(0, eventQueue); + } + static void writeToEventQueue(String fileContent, boolean delete) throws IOException { File file = new File(TestUtils.getTestSDirectory(), FILE_NAME_PREFIX + FILE_NAME_SEPARATOR + EVENT_QUEUE_FILE_NAME); file.createNewFile(); diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInternalEventConsentTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInternalEventConsentTests.java new file mode 100644 index 000000000..c00774405 --- /dev/null +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInternalEventConsentTests.java @@ -0,0 +1,642 @@ +package ly.count.sdk.java.internal; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.ObjectOutputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import ly.count.sdk.java.Config; +import ly.count.sdk.java.Countly; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * The events the SDK records for its own features, views and feedback widget results, are recorded + * under the consent of their feature, without the events consent, which custom events still need. + */ +@RunWith(JUnit4.class) +public class ScenarioInternalEventConsentTests { + private static final long WAIT_BUDGET_MS = 60_000; + + private EventServer server; + + @Before + public void beforeTest() { + TestUtils.createCleanTestState(); + } + + @After + public void afterTest() { + Countly.instance().halt(); + if (server != null) { + server.stop(); + } + } + + // region scenarios + + /** + * The views consent alone is enough to record views and send them. + *

+ * Verifies against a local server, with consent required and only the views consent given, that a + * started and stopped view reach the server as two view events in one request, both with the view + * ID and the first with the visit and start keys, while a custom event recorded in between is + * ignored and the events feature is not tracked. + */ + @Test + public void viewsConsentAlone_viewsAreRecordedAndSent_customEventsAreNot() throws Exception { + server = new EventServer(); + Countly.instance().init(serverConfig().enableFeatures(Config.Feature.Events, Config.Feature.Views) + .setRequiresConsent(true).setEventQueueSizeToSend(2)); + Countly.onConsent(Config.Feature.Views); + + String viewId = Countly.instance().views().startView("home"); + Countly.instance().events().recordEvent("custom_without_consent"); + Countly.instance().views().stopViewWithID(viewId); + + List events = server.awaitEvents(2); + Assert.assertEquals(Arrays.asList(ModuleViews.KEY_VIEW_EVENT, ModuleViews.KEY_VIEW_EVENT), keys(events)); + Assert.assertEquals(1, server.eventRequests().size()); + TestUtils.validateRequiredParams(server.eventRequests().get(0)); + JSONObject start = events.get(0).getJSONObject("segmentation"); + Assert.assertEquals(viewId, events.get(0).getString("id")); + Assert.assertEquals("home", start.getString(ModuleViews.KEY_NAME)); + Assert.assertEquals(ModuleViews.KEY_VISIT_VALUE, start.getString(ModuleViews.KEY_VISIT)); + Assert.assertEquals(ModuleViews.KEY_START_VALUE, start.getString(ModuleViews.KEY_START)); + JSONObject end = events.get(1).getJSONObject("segmentation"); + Assert.assertEquals(viewId, events.get(1).getString("id")); + Assert.assertEquals("home", end.getString(ModuleViews.KEY_NAME)); + Assert.assertFalse(end.has(ModuleViews.KEY_VISIT)); + Assert.assertFalse(Countly.isTracking(Config.Feature.Events)); + Assert.assertTrue(Countly.isTracking(Config.Feature.Views)); + TestUtils.validateEQSize(0); + } + + /** + * The feedback consent alone is enough to record a feedback widget result and send it. + *

+ * Verifies against a local server, with consent required and only the feedback consent given, + * that a reported NPS result reaches the server as one NPS event with the widget ID, the platform, + * the application version and the answers, and that the events feature is not tracked. + */ + @Test + public void feedbackConsentAlone_aWidgetResultIsRecordedAndSent() throws Exception { + server = new EventServer(); + Countly.instance().init(serverConfig().enableFeatures(Config.Feature.Events, Config.Feature.Feedback) + .setRequiresConsent(true).setEventQueueSizeToSend(1)); + Countly.onConsent(Config.Feature.Feedback); + + Countly.instance().feedback().reportFeedbackWidgetManually(widget(FeedbackWidgetType.nps, "nps_widget"), null, TestUtils.map("rating", 7, "comment", "fine")); + + List events = server.awaitEvents(1); + Assert.assertEquals(Collections.singletonList(FeedbackWidgetType.nps.eventKey), keys(events)); + TestUtils.validateRequiredParams(server.eventRequests().get(0)); + JSONObject segmentation = events.get(0).getJSONObject("segmentation"); + Assert.assertEquals("nps_widget", segmentation.getString("widget_id")); + Assert.assertEquals(WidgetUrlBuilder.PLATFORM, segmentation.getString("platform")); + Assert.assertEquals(TestUtils.APPLICATION_VERSION, segmentation.getString("app_version")); + Assert.assertEquals(7, segmentation.getInt("rating")); + Assert.assertEquals("fine", segmentation.getString("comment")); + Assert.assertEquals(5, segmentation.length()); + Assert.assertFalse(Countly.isTracking(Config.Feature.Events)); + } + + /** + * A feedback widget result needs the feedback consent, which the events consent does not stand in + * for. + *

+ * Verifies that with only the events consent given a reported result is ignored, and that once + * the feedback consent is given the same result is recorded. + */ + @Test + public void eventsConsentAlone_doesNotRecordAWidgetResult() { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Events, Config.Feature.Feedback) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + Countly.onConsent(Config.Feature.Events); + CountlyFeedbackWidget nps = widget(FeedbackWidgetType.nps, "nps_widget"); + + Countly.instance().feedback().reportFeedbackWidgetManually(nps, null, null); + TestUtils.validateEQSize(0); + + Countly.onConsent(Config.Feature.Feedback); + Countly.instance().feedback().reportFeedbackWidgetManually(nps, null, null); + Assert.assertEquals(Collections.singletonList(FeedbackWidgetType.nps.eventKey), TestUtils.keysInEQ()); + } + + /** + * Without a consent requirement, views and feedback widget results are recorded when only their + * own features are enabled, while custom events stay off with the events feature. + *

+ * Verifies that a view started before init is ignored, that after init the events feature is not + * tracked and its interface ignores calls, and that a view and a rating result are queued in the + * order they were recorded. + */ + @Test + public void withoutConsentRequired_viewsAndWidgetResultsAreRecorded_withoutTheEventsFeature() { + Assert.assertNull(Countly.instance().views().startView("before_init")); + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Views, Config.Feature.Feedback) + .setEventQueueSizeToSend(100)); + + Assert.assertFalse(Countly.isTracking(Config.Feature.Events)); + Assert.assertSame(SDKCore.dormantEvents, Countly.instance().events()); + Countly.instance().events().recordEvent("custom_without_the_feature"); + Assert.assertFalse(Countly.instance().events().startEvent("timed_without_the_feature")); + + Assert.assertNotNull(Countly.instance().views().startView("home")); + Countly.instance().feedback().reportFeedbackWidgetManually(widget(FeedbackWidgetType.rating, "rating_widget"), null, TestUtils.map("rating", 4)); + + List queued = TestUtils.getCurrentEQ(); + Assert.assertEquals(Arrays.asList(ModuleViews.KEY_VIEW_EVENT, FeedbackWidgetType.rating.eventKey), TestUtils.keysInEQ()); + Assert.assertEquals("home", queued.get(0).segmentation.get(ModuleViews.KEY_NAME)); + Assert.assertEquals("rating_widget", queued.get(1).segmentation.get("widget_id")); + Assert.assertEquals(4, queued.get(1).segmentation.get("rating")); + } + + /** + * Removing the events consent drops the custom and timed events not sent yet, and nothing the + * views feature recorded, which keeps recording. + *

+ * Verifies that after the removal only the view event is left in the queue and no timed event is + * left, that an events interface kept from before ignores calls while a new view is recorded, and + * that once the events consent is given again a custom event is queued after the views without a + * previous event ID pointing at a dropped event. + */ + @Test + public void removingTheEventsConsent_dropsCustomAndTimedEvents_andKeepsTheViews() { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Events, Config.Feature.Views) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + Countly.onConsent(Config.Feature.Events, Config.Feature.Views); + ModuleEvents.Events kept = Countly.instance().events(); + kept.recordEvent("custom_before_removal"); + Assert.assertTrue(kept.startEvent("timed_before_removal")); + Assert.assertNotNull(Countly.instance().views().startView("home")); + Assert.assertEquals(Arrays.asList("custom_before_removal", ModuleViews.KEY_VIEW_EVENT), TestUtils.keysInEQ()); + + Countly.onConsentRemoval(Config.Feature.Events); + + Assert.assertEquals(Collections.singletonList(ModuleViews.KEY_VIEW_EVENT), TestUtils.keysInEQ()); + Assert.assertTrue(SDKCore.instance.module(ModuleEvents.class).timedEvents.isEmpty()); + Assert.assertFalse(Countly.isTracking(Config.Feature.Events)); + Assert.assertSame(SDKCore.dormantEvents, Countly.instance().events()); + kept.recordEvent("ignored_after_removal"); + Assert.assertFalse(kept.endEvent("timed_before_removal")); + Assert.assertNotNull(Countly.instance().views().startView("after_removal")); + Assert.assertEquals(Arrays.asList(ModuleViews.KEY_VIEW_EVENT, ModuleViews.KEY_VIEW_EVENT), TestUtils.keysInEQ()); + + Countly.onConsent(Config.Feature.Events); + kept.recordEvent("after_consent_again"); + Assert.assertEquals(Arrays.asList(ModuleViews.KEY_VIEW_EVENT, ModuleViews.KEY_VIEW_EVENT, "after_consent_again"), TestUtils.keysInEQ()); + Assert.assertNull(TestUtils.getCurrentEQ().get(2).peid); + } + + /** + * Removing every consent drops every event not sent yet, the views and feedback widget results + * included, as their own consent goes with it. + *

+ * Verifies that after a custom event, a view and an NPS result are queued with every consent + * given, removing every consent leaves nothing in the event queue and queues no request. + */ + @Test + public void removingEveryConsent_dropsTheQueuedViewsAndWidgetResultsToo() { + Countly.instance().init(TestUtils.getBaseConfig() + .enableFeatures(Config.Feature.Events, Config.Feature.Views, Config.Feature.Feedback) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + Countly.onConsent(Config.Feature.Events, Config.Feature.Views, Config.Feature.Feedback); + Countly.instance().events().recordEvent("custom_before_opt_out"); + Assert.assertNotNull(Countly.instance().views().startView("home")); + Countly.instance().feedback().reportFeedbackWidgetManually(widget(FeedbackWidgetType.nps, "nps_widget"), null, null); + Assert.assertEquals(Arrays.asList("custom_before_opt_out", ModuleViews.KEY_VIEW_EVENT, FeedbackWidgetType.nps.eventKey), TestUtils.keysInEQ()); + + Countly.onConsentRemoval(Config.Feature.values()); + + TestUtils.validateEQSize(0); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + } + + /** + * Custom events left in the queue by an earlier run wait for the events consent, while the views + * recorded meanwhile are sent. + *

+ * Verifies against a local server that, after a restart with only the views consent given, the + * request the first view triggers carries that view alone while the custom event of the earlier + * run stays queued, and that once the events consent is given the next request carries that event + * and a new one, in the order they were recorded. + */ + @Test + public void customEventsOfAnEarlierRun_waitForTheEventsConsent_whileViewsAreSent() throws Exception { + server = new EventServer(); + Countly.instance().init(serverConfig().enableFeatures(Config.Feature.Events, Config.Feature.Views) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + Countly.onConsent(Config.Feature.Events); + Countly.instance().events().recordEvent("from_an_earlier_run"); + Countly.instance().stop(); + Assert.assertEquals(Collections.singletonList("from_an_earlier_run"), TestUtils.keysInEQ()); + + Countly.instance().init(serverConfig().enableFeatures(Config.Feature.Events, Config.Feature.Views) + .setRequiresConsent(true).setEventQueueSizeToSend(1)); + Countly.onConsent(Config.Feature.Views); + Assert.assertNotNull(Countly.instance().views().startView("home")); + + Assert.assertEquals(Collections.singletonList(ModuleViews.KEY_VIEW_EVENT), keys(server.awaitEvents(1))); + Assert.assertEquals(Collections.singletonList("from_an_earlier_run"), TestUtils.keysInEQ()); + + Countly.onConsent(Config.Feature.Events); + Countly.instance().events().recordEvent("after_the_consent"); + + List events = server.awaitEvents(3); + Assert.assertEquals(Arrays.asList(ModuleViews.KEY_VIEW_EVENT, "from_an_earlier_run", "after_the_consent"), keys(events)); + Assert.assertEquals(2, server.eventRequests().size()); + TestUtils.validateEQSize(0); + } + + /** + * Custom events waiting for the events consent do not count toward the event queue threshold, so + * the views still go in batches. + *

+ * Verifies that, after a restart with a threshold below the number of custom events left by the + * earlier run and only the views consent given, the first view event stays queued and the second + * one sends both in one request, while the custom events stay queued. + */ + @Test + public void customEventsWaitingForTheEventsConsent_doNotCountTowardTheThreshold() { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Events, Config.Feature.Views) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + Countly.onConsent(Config.Feature.Events); + Countly.instance().events().recordEvent("custom_1"); + Countly.instance().events().recordEvent("custom_2"); + Countly.instance().events().recordEvent("custom_3"); + Countly.instance().stop(); + + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Events, Config.Feature.Views) + .setRequiresConsent(true).setEventQueueSizeToSend(2)); + Countly.onConsent(Config.Feature.Views); + String viewId = Countly.instance().views().startView("home"); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + + Countly.instance().views().stopViewWithID(viewId); + + Map[] requests = TestUtils.getCurrentRQ(); + Assert.assertEquals(1, requests.length); + TestUtils.validateRequiredParams(requests[0]); + Assert.assertEquals(Arrays.asList(ModuleViews.KEY_VIEW_EVENT, ModuleViews.KEY_VIEW_EVENT), keys(eventsOf(requests[0]))); + Assert.assertEquals(Arrays.asList("custom_1", "custom_2", "custom_3"), TestUtils.keysInEQ()); + } + + /** + * Timed events stored by an earlier run through the deprecated session API stay in that store + * while the events feature does not run, so nothing can end them into custom events. + *

+ * Verifies, with only the views consent given, that restoring a stored timed event keeps it in the + * deprecated store and out of the events module, and that a device ID change without merge, which + * ends the timed events of the module, records no custom event. + */ + @Test + public void timedEventsOfAnEarlierRun_stayInTheDeprecatedStore_whileTheEventsFeatureDoesNotRun() throws IOException { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Events, Config.Feature.Views) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + Countly.onConsent(Config.Feature.Views); + Log logger = SDKCore.instance.config.getLogger(); + TimedEvents stored = new TimedEvents(); + + Assert.assertTrue(stored.restore(storedTimedEvent("timed_of_an_earlier_run", logger), logger)); + + Assert.assertEquals(1, stored.size()); + Assert.assertTrue(SDKCore.instance.module(ModuleEvents.class).timedEvents.isEmpty()); + Countly.instance().deviceId().changeWithoutMerge("another_device_id"); + Assert.assertEquals(Collections.emptyList(), allQueuedEventKeys()); + } + + /** + * A custom event that reaches the events module another way than through the events interface, + * as a timed event the deprecated API handed over, is not recorded while the events feature does + * not run. + *

+ * Verifies, with only the views consent given, that a custom event recorded straight into the + * module is dropped, and that a device ID change without merge drops the timed event it ends while + * it records the start and the end of the running view. + */ + @Test + public void customEventsReachingTheModuleAnotherWay_areNotRecorded_whileTheEventsFeatureDoesNotRun() { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Events, Config.Feature.Views) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + Countly.onConsent(Config.Feature.Views); + ModuleEvents module = SDKCore.instance.module(ModuleEvents.class); + + module.recordEventInternal("custom_from_inside", 1, null, null, null, null); + TestUtils.validateEQSize(0); + + module.timedEvents.put("timed_from_inside", new EventImpl(event -> { + }, "timed_from_inside", SDKCore.instance.config.getLogger())); + Assert.assertNotNull(Countly.instance().views().startView("home")); + Countly.instance().deviceId().changeWithoutMerge("another_device_id"); + + Assert.assertTrue(module.timedEvents.isEmpty()); + Assert.assertEquals(Arrays.asList(ModuleViews.KEY_VIEW_EVENT, ModuleViews.KEY_VIEW_EVENT), allQueuedEventKeys()); + } + + /** + * Saving user properties sends the views queued before them first, without the events consent as + * with it, so the server gets the views before the properties change. + *

+ * Verifies that with only the views and user profiles consents given, saving a property queues + * the request with the view before the one with the property, and empties the event queue. + */ + @Test + public void savingUserProperties_queuesTheViewsRecordedBeforeThemFirst() { + Countly.instance().init(TestUtils.getBaseConfig() + .enableFeatures(Config.Feature.Events, Config.Feature.Views, Config.Feature.UserProfiles) + .setRequiresConsent(true).setEventQueueSizeToSend(100)); + Countly.onConsent(Config.Feature.Views, Config.Feature.UserProfiles); + Assert.assertNotNull(Countly.instance().views().startView("home")); + + Countly.instance().userProfile().setProperty("plan", "pro"); + Countly.instance().userProfile().save(); + + Map[] requests = TestUtils.getCurrentRQ(); + Assert.assertEquals(2, requests.length); + TestUtils.validateRequiredParams(requests[0]); + Assert.assertEquals(Collections.singletonList(ModuleViews.KEY_VIEW_EVENT), keys(eventsOf(requests[0]))); + Assert.assertFalse(requests[0].containsKey("user_details")); + Assert.assertTrue(requests[1].get("user_details").contains("\"plan\":\"pro\"")); + TestUtils.validateEQSize(0); + } + + /** + * A view the server does not accept is kept and sent again. + *

+ * Verifies against a local server that answers the first request with an error, with only the + * views feature enabled, that the request with the first view arrives again before the one with + * the second view, and that the request queue then drains. + */ + @Test + public void aViewTheServerDoesNotAccept_isSentAgain() throws Exception { + server = new EventServer(); + server.failTheNextAnswer(); + Countly.instance().init(serverConfig().enableFeatures(Config.Feature.Views).setEventQueueSizeToSend(1)); + + Assert.assertNotNull(Countly.instance().views().startView("first")); + server.awaitEvents(1); + Assert.assertNotNull(Countly.instance().views().startView("second")); + + List events = server.awaitEvents(3); + Assert.assertEquals(Arrays.asList("first", "first", "second"), viewNames(events)); + Assert.assertTrue("the request queue never drained", TestUtils.waitFor(WAIT_BUDGET_MS, () -> TestUtils.getCurrentRQ().length == 0)); + } + + /** + * Content events are custom events, so they still need the events feature. + *

+ * Verifies that with the content feature enabled and the events feature not, the events a content + * block reports are dropped. + */ + @Test + public void contentEvents_stillNeedTheEventsFeature() { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Content).setEventQueueSizeToSend(100)); + + SDKCore.instance.module(ModuleContent.class).recordContentEventsInternal("[{\"key\":\"[CLY]_content_shown\",\"sg\":{\"id\":\"content_1\"}}]"); + + TestUtils.validateEQSize(0); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + } + + // endregion + // region helpers + + /** + * A configuration pointing at {@link #server}, without SDK behavior settings requests. + * + * @return the configuration + */ + private Config serverConfig() { + File directory = TestUtils.getTestSDirectory(); + TestUtils.checkSdkStorageRootDirectoryExist(directory); + return new Config("http://127.0.0.1:" + server.port(), TestUtils.SERVER_APP_KEY, directory) + .setApplicationVersion(TestUtils.APPLICATION_VERSION) + .setCustomDeviceId(TestUtils.DEVICE_ID) + .setNetworkRequestCooldown(0) + .setNetworkImportantRequestCooldown(0) + .disableSdkBehaviorSettingsUpdates(); + } + + /** + * A feedback widget as the widget list of the server describes it. + * + * @param type the type of the widget + * @param id the ID of the widget + * @return the widget + */ + private static CountlyFeedbackWidget widget(FeedbackWidgetType type, String id) { + CountlyFeedbackWidget widget = new CountlyFeedbackWidget(); + widget.type = type; + widget.widgetId = id; + widget.name = id + "_name"; + widget.tags = new String[] {}; + return widget; + } + + /** + * The keys of the given events, in order. + * + * @param events the events + * @return the keys + */ + private static List keys(List events) { + List keys = new ArrayList<>(); + for (JSONObject event : events) { + keys.add(event.getString("key")); + } + return keys; + } + + /** + * The events a request carries, in order. + * + * @param request the parameters of the request + * @return the events, none when the request carries no events parameter + */ + private static List eventsOf(Map request) { + List events = new ArrayList<>(); + String parameter = request == null ? null : request.get("events"); + if (parameter != null) { + JSONArray array = new JSONArray(parameter); + for (int i = 0; i < array.length(); i++) { + events.add(array.getJSONObject(i)); + } + } + return events; + } + + /** + * The keys of every event waiting to be sent: those in the event queue, then those in the queued + * requests. + * + * @return the keys + */ + private static List allQueuedEventKeys() { + List keys = new ArrayList<>(TestUtils.keysInEQ()); + for (Map request : TestUtils.getCurrentRQ()) { + keys.addAll(keys(eventsOf(request))); + } + return keys; + } + + /** + * One timed event as the deprecated timed events store writes it. + * + * @param key the key of the timed event + * @param logger the logger the event serializes with + * @return the stored bytes + * @throws IOException when the bytes cannot be written + */ + private static byte[] storedTimedEvent(String key, Log logger) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream stream = new ObjectOutputStream(bytes)) { + stream.writeInt(1); + stream.writeUTF(key); + stream.writeUTF(new EventImpl(key, 1, null, null, null, logger, null, null, null, null).toJSON(logger)); + } + return bytes.toByteArray(); + } + + /** + * The view names of the given view events, in order. + * + * @param events the view events + * @return the names + */ + private static List viewNames(List events) { + List names = new ArrayList<>(); + for (JSONObject event : events) { + names.add(event.getJSONObject("segmentation").getString(ModuleViews.KEY_NAME)); + } + return names; + } + + /** + * A local HTTP server standing in for the Countly server: it records every request in the order + * it arrives and accepts it, except the one a test asks it to answer with an error. + */ + private static final class EventServer { + private final HttpServer http; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final List> received = new CopyOnWriteArrayList<>(); + private final AtomicBoolean failNextAnswer = new AtomicBoolean(); + + /** + * Starts the server on a free loopback port. + * + * @throws IOException when the server cannot start + */ + EventServer() throws IOException { + http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + http.createContext("/", this::handle); + http.setExecutor(executor); + http.start(); + } + + /** + * Answers the next request with a server error. + */ + void failTheNextAnswer() { + failNextAnswer.set(true); + } + + /** + * Records a request and answers it. + * + * @param exchange the request and its response + * @throws IOException when the response cannot be written + */ + private void handle(HttpExchange exchange) throws IOException { + String query = exchange.getRequestURI().getRawQuery(); + String body = new String(Utils.readStream(exchange.getRequestBody(), null), StandardCharsets.UTF_8); + received.add(new Params(query != null && !query.isEmpty() ? query : body).map()); + + boolean fail = failNextAnswer.getAndSet(false); + byte[] response = (fail ? "{\"error\":\"not accepted\"}" : "{\"result\":\"Success\"}").getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(fail ? 500 : 200, response.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response); + } + } + + /** + * The requests that carried events, in the order they arrived. + * + * @return the parameters of each request + */ + List> eventRequests() { + List> withEvents = new ArrayList<>(); + for (Map request : received) { + if (request.containsKey("events")) { + withEvents.add(request); + } + } + return withEvents; + } + + /** + * Waits until the requests carried at least the given number of events. + * + * @param count how many events to wait for + * @return every event the requests carried, in the order they arrived + * @throws InterruptedException when the wait is interrupted + */ + List awaitEvents(int count) throws InterruptedException { + Assert.assertTrue("expected " + count + " events, the server got: " + received, TestUtils.waitFor(WAIT_BUDGET_MS, () -> events().size() >= count)); + return events(); + } + + /** + * Every event the requests carried, in the order they arrived. + * + * @return the events + */ + private List events() { + List events = new ArrayList<>(); + for (Map request : received) { + events.addAll(eventsOf(request)); + } + return events; + } + + /** + * The port the server listens on. + * + * @return the port + */ + int port() { + return http.getAddress().getPort(); + } + + /** + * Stops the server and its handler thread. + */ + void stop() { + http.stop(0); + executor.shutdownNow(); + } + + } + + // endregion +} diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ViewImplTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ViewImplTests.java index 4dff4cbaf..9e2544e87 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ViewImplTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ViewImplTests.java @@ -1,5 +1,6 @@ package ly.count.sdk.java.internal; +import java.util.Collections; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import ly.count.sdk.java.Config; @@ -98,15 +99,15 @@ public void start_nullAndEmptyName() { /** * "start" with no consent to Events - * Validating that view is not recorded - * A view should not be recorded + * Validating that view is recorded + * A view needs only the views feature, so it should be recorded */ @Test public void start_noEventsConsent() { Countly.instance().init(TestUtils.getBaseConfig().setFeatures(Config.Feature.Views)); TestUtils.validateEQSize(0); Countly.instance().view(TestUtils.keysValues[0]); // this calls start automatically - TestUtils.validateEQSize(0); + Assert.assertEquals(Collections.singletonList(ModuleViews.KEY_VIEW_EVENT), TestUtils.keysInEQ()); } /** @@ -186,17 +187,17 @@ public void stop_backendModeEnabled() { /** * "stop" with no consent to events - * Validating that nothing should be recorded - * Event queue should be empty + * Validating that start view and stop view are recorded + * Views need only the views feature, so the event queue should hold the four view events */ @Test public void stop_noConsentForEvents() { Countly.instance().init(TestUtils.getBaseConfig().setFeatures(Config.Feature.Views)); TestUtils.validateEQSize(0); Countly.instance().view(TestUtils.keysValues[0]); // this calls start automatically - TestUtils.validateEQSize(0); + TestUtils.validateEQSize(1); Countly.instance().view(TestUtils.keysValues[0]).stop(false); // this call stop previous view and creates new one and stops it - TestUtils.validateEQSize(0); + Assert.assertEquals(Collections.nCopies(4, ModuleViews.KEY_VIEW_EVENT), TestUtils.keysInEQ()); } /**