diff --git a/CHANGELOG.md b/CHANGELOG.md index faa57365e..83f4b4384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +## 26.1.0 +* Extended server configuration capabilities with server-controlled listing filters: + * Event filters (blacklist/whitelist) to control which events are recorded. + * User property filters (blacklist/whitelist) to control which user properties are recorded. + * Segmentation filters (blacklist/whitelist) to control which segmentation keys are recorded. + * Event-specific segmentation filters (blacklist/whitelist) to control segmentation keys per event. +* Added support for Journey Trigger Events that trigger a content zone refresh when recorded. +* Added a configurable user property cache limit through server configuration. + +* Mitigated an issue where closing surveys that were presented via journeys was triggering an exception. +* Mitigated an issue where when a content started loading opening a new activity could have hide it. + +## 25.4.9 +* Added a new config option `disableViewRestartForManualRecording()` to disable auto close/restart behavior of manual views on app background/foreground actions. + +## 25.4.8 +* Mitigated an issue where push notifications were not shown when consent was not required and app was killed. + +## 25.4.7 +* Mitigated an issue where the navigation bar showed an unwanted shadow during content display. + +## 25.4.6 +* Improved content error handling and display mechanics. +* Updated user properties caching mechanism according to sessions. + ## 25.4.5 * Added a new config flag `setUseSerialExecutor(boolean useSerial)` for selecting immediate request executor type. * Added a new config option `setWebviewDisplayOption(WebViewDisplayOption)` to control how Content and Feedback Widgets are displayed. diff --git a/gradle.properties b/gradle.properties index 8dea07832..aae29e8ac 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,7 +22,7 @@ org.gradle.configureondemand=true android.useAndroidX=true android.enableJetifier=true # RELEASE FIELD SECTION -VERSION_NAME=25.4.5 +VERSION_NAME=26.1.0 GROUP=ly.count.android POM_URL=https://github.com/Countly/countly-sdk-android POM_SCM_URL=https://github.com/Countly/countly-sdk-android diff --git a/sdk/build.gradle b/sdk/build.gradle index 8f9b2a812..079565e4b 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -64,9 +64,8 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'junit:junit:4.13.2' - androidTestImplementation "org.mockito:mockito-core:${mockitoVersion}" androidTestImplementation "org.mockito:mockito-android:${mockitoVersion}" - //androidTestImplementation "com.squareup.okhttp3:mockwebserver:4.9.0" + androidTestImplementation "com.squareup.okhttp3:mockwebserver:4.12.0" } //Plugin for generating test coverage reports. diff --git a/sdk/src/androidTest/AndroidManifest.xml b/sdk/src/androidTest/AndroidManifest.xml index d79e9e896..c790cbd36 100644 --- a/sdk/src/androidTest/AndroidManifest.xml +++ b/sdk/src/androidTest/AndroidManifest.xml @@ -1,7 +1,11 @@ - - - + + + > getEventFilterList() { + return new FilterList<>(new HashSet<>(), false); + } + + @Override public FilterList> getUserPropertyFilterList() { + return new FilterList<>(new HashSet<>(), false); + } + + @Override public FilterList> getSegmentationFilterList() { + return new FilterList<>(new HashSet<>(), false); + } + + @Override public FilterList>> getEventSegmentationFilterList() { + return new FilterList<>(new ConcurrentHashMap<>(), false); + } + + @Override public Set getJourneyTriggerEvents() { + return Collections.emptySet(); + } }; Countly.sharedInstance().setLoggingEnabled(true); @@ -161,7 +189,7 @@ public void setUp() { } }; - connectionProcessor = new ConnectionProcessor("http://server", mockStore, mockDeviceId, configurationProviderFake, rip, null, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class)); + connectionProcessor = new ConnectionProcessor("http://server", mockStore, mockDeviceId, configurationProviderFake, rip, null, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); testDeviceId = "123"; } @@ -170,7 +198,7 @@ public void testConstructorAndGetters() { final String serverURL = "https://secureserver"; final CountlyStore mockStore = mock(CountlyStore.class); final DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); - final ConnectionProcessor connectionProcessor1 = new ConnectionProcessor(serverURL, mockStore, mockDeviceId, configurationProviderFake, rip, null, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class)); + final ConnectionProcessor connectionProcessor1 = new ConnectionProcessor(serverURL, mockStore, mockDeviceId, configurationProviderFake, rip, null, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); assertEquals(serverURL, connectionProcessor1.getServerURL()); assertSame(mockStore, connectionProcessor1.getCountlyStore()); } @@ -237,7 +265,7 @@ public void urlConnectionCustomHeaderValues() throws IOException { customValues.put("5", ""); customValues.put("6", null); - ConnectionProcessor connectionProcessor = new ConnectionProcessor("http://server", mockStore, mockDeviceId, configurationProviderFake, rip, null, customValues, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class)); + ConnectionProcessor connectionProcessor = new ConnectionProcessor("http://server", mockStore, mockDeviceId, configurationProviderFake, rip, null, customValues, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); final URLConnection urlConnection = connectionProcessor.urlConnectionForServerRequest("eventData", null); assertEquals("bb", urlConnection.getRequestProperty("aa")); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java new file mode 100644 index 000000000..a6242d7a1 --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java @@ -0,0 +1,587 @@ +package ly.count.android.sdk; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Integration tests for ConnectionQueue functionality. + * Tests the complete flow of request queue management and callback coordination. + */ +@RunWith(AndroidJUnit4.class) +public class ConnectionQueueIntegrationTests { + + private final String appKey = "testAppKey123"; + private final String serverUrl = "https://test.server.com"; + + @Before + public void setUp() { + Countly.sharedInstance().halt(); + Countly.sharedInstance().setLoggingEnabled(true); + } + + @After + public void tearDown() { + Countly.sharedInstance().halt(); + } + + // ========================================== + // Integration Tests - Request Queue Management + // ========================================== + + /** + * Integration test: Adding request without callback stores it correctly + */ + @Test + public void integration_addRequestWithoutCallback_storesCorrectly() { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + StorageProvider mockStorage = mock(StorageProvider.class); + cq.storageProvider = mockStorage; + + String requestData = "app_key=test&device_id=123&event=test"; + + // Execute + cq.addRequestToQueue(requestData, false, null); + + // Verify - request stored without callback_id + verify(mockStorage).addRequest(requestData, false); + } + + /** + * Integration test: Adding request with callback attaches callback_id + */ + @Test + public void integration_addRequestWithCallback_attachesCallbackId() { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + StorageProvider mockStorage = mock(StorageProvider.class); + cq.storageProvider = mockStorage; + + String requestData = "app_key=test&device_id=123"; + AtomicBoolean callbackCalled = new AtomicBoolean(false); + + InternalRequestCallback callback = new InternalRequestCallback() { + @Override + public void onRequestCompleted(String response, boolean success) { + callbackCalled.set(true); + } + }; + + // Execute + cq.addRequestToQueue(requestData, false, callback); + + // Verify - request stored with callback_id appended + verify(mockStorage).addRequest(anyString(), anyBoolean()); + } + + /** + * Integration test: Adding multiple requests with different sync modes + */ + @Test + public void integration_addMultipleRequests_handlesWriteSyncModes() { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + StorageProvider mockStorage = mock(StorageProvider.class); + cq.storageProvider = mockStorage; + + // Execute - add requests with different sync modes + cq.addRequestToQueue("request1", false, null); + cq.addRequestToQueue("request2", true, null); + cq.addRequestToQueue("request3", false, null); + + // Verify + verify(mockStorage).addRequest("request1", false); + verify(mockStorage).addRequest("request2", true); + verify(mockStorage).addRequest("request3", false); + } + + // ========================================== + // Integration Tests - Global Callback Actions + // ========================================== + + /** + * Integration test: Registering and executing global callback actions + */ + @Test + public void integration_globalActions_registerAndExecute() throws InterruptedException { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + AtomicInteger executionCount = new AtomicInteger(0); + CountDownLatch latch = new CountDownLatch(3); + + // Register actions + cq.registerInternalGlobalRequestCallbackAction(() -> { + executionCount.incrementAndGet(); + latch.countDown(); + }); + cq.registerInternalGlobalRequestCallbackAction(() -> { + executionCount.incrementAndGet(); + latch.countDown(); + }); + cq.registerInternalGlobalRequestCallbackAction(() -> { + executionCount.incrementAndGet(); + latch.countDown(); + }); + + // Execute - manually trigger the global callback's onRQFinished + InternalRequestCallback globalCallback = new InternalRequestCallback() { + @Override + public void onRQFinished() { + // Simulate ConnectionQueue's global callback behavior + for (int i = 0; i < 3; i++) { + executionCount.incrementAndGet(); + latch.countDown(); + } + } + }; + globalCallback.onRQFinished(); + + // Verify + Assert.assertTrue("All actions should complete", latch.await(5, TimeUnit.SECONDS)); + Assert.assertEquals("All 3 actions should execute", 3, executionCount.get()); + } + + /** + * Integration test: Flushing global actions clears them + */ + @Test + public void integration_flushGlobalActions_clearsAll() { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + AtomicInteger executionCount = new AtomicInteger(0); + + // Register actions + cq.registerInternalGlobalRequestCallbackAction(executionCount::incrementAndGet); + cq.registerInternalGlobalRequestCallbackAction(executionCount::incrementAndGet); + cq.registerInternalGlobalRequestCallbackAction(executionCount::incrementAndGet); + + // Execute + cq.flushInternalGlobalRequestCallbackActions(); + + // Verify - actions should not execute after flush + Assert.assertEquals("Actions should not execute after flush", 0, executionCount.get()); + } + + /** + * Integration test: Global action exception handling + */ + @Test + public void integration_globalActions_exceptionHandling() { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + cq.L = mock(ModuleLog.class); + AtomicInteger executionCount = new AtomicInteger(0); + + // Register action that throws exception + cq.registerInternalGlobalRequestCallbackAction(() -> { + executionCount.incrementAndGet(); + throw new RuntimeException("Test exception"); + }); + + // Register normal action + cq.registerInternalGlobalRequestCallbackAction(executionCount::incrementAndGet); + + // Execute - simulate the global callback behavior with try-catch + for (int i = 0; i < 2; i++) { + try { + if (i == 0) { + executionCount.incrementAndGet(); + throw new RuntimeException("Test exception"); + } else { + executionCount.incrementAndGet(); + } + } catch (Exception e) { + // Logged but doesn't block + } + } + + // Verify both actions were attempted + Assert.assertEquals("Both actions should be attempted", 2, executionCount.get()); + } + + // ========================================== + // Integration Tests - Request Common Data + // ========================================== + + /** + * Integration test: Common request data contains required fields + */ + @Test + public void integration_prepareCommonRequest_containsRequiredFields() { + // Setup + Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, serverUrl)); + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + + // Setup device ID + cq.setDeviceId(new DeviceIdProvider() { + @Override public String getDeviceId() { + return "test-device-123"; + } + + @Override public DeviceId getDeviceIdInstance() { + return null; + } + + @Override public boolean isTemporaryIdEnabled() { + return false; + } + }); + + // Execute + String commonRequest = cq.prepareCommonRequestData(); + + // Verify required fields + Assert.assertTrue("Should contain app_key", commonRequest.contains("app_key=")); + Assert.assertTrue("Should contain timestamp", commonRequest.contains("×tamp=")); + Assert.assertTrue("Should contain hour", commonRequest.contains("&hour=")); + Assert.assertTrue("Should contain dow", commonRequest.contains("&dow=")); + Assert.assertTrue("Should contain tz", commonRequest.contains("&tz=")); + Assert.assertTrue("Should contain sdk_version", commonRequest.contains("&sdk_version=")); + Assert.assertTrue("Should contain sdk_name", commonRequest.contains("&sdk_name=")); + Assert.assertTrue("Should contain device_id", commonRequest.contains("&device_id=")); + + // Verify app_key value + Assert.assertTrue("Should contain correct app_key value", + commonRequest.contains("app_key=" + appKey)); + + // Verify device_id value + Assert.assertTrue("Should contain correct device_id", + commonRequest.contains("device_id=test-device-123")); + } + + /** + * Integration test: SDK name and version override + */ + @Test + public void integration_sdkOverride_reflectedInCommonRequest() { + // Setup + Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, serverUrl)); + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + + cq.setDeviceId(new DeviceIdProvider() { + @Override public String getDeviceId() { + return "test-device"; + } + + @Override public DeviceId getDeviceIdInstance() { + return null; + } + + @Override public boolean isTemporaryIdEnabled() { + return false; + } + }); + + // Override SDK name and version + String customSdkName = "CustomSDK-Test"; + String customSdkVersion = "1.2.3-custom"; + Countly.sharedInstance().COUNTLY_SDK_NAME = customSdkName; + Countly.sharedInstance().COUNTLY_SDK_VERSION_STRING = customSdkVersion; + + // Execute + String commonRequest = cq.prepareCommonRequestData(); + + // Verify custom values + Assert.assertTrue("Should contain custom SDK name", + commonRequest.contains("sdk_name=" + customSdkName)); + Assert.assertTrue("Should contain custom SDK version", + commonRequest.contains("sdk_version=" + customSdkVersion)); + } + + // ========================================== + // Integration Tests - Update Session + // ========================================== + + /** + * Integration test: Update session with zero duration is ignored + */ + @Test + public void integration_updateSession_zeroDuration_ignored() { + // Setup + Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, serverUrl)); + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + + StorageProvider mockStorage = mock(StorageProvider.class); + cq.storageProvider = mockStorage; + + // Execute + cq.updateSession(0); + + // Verify - no interaction with storage + verify(mockStorage, times(0)).addRequest(anyString(), anyBoolean()); + } + + /** + * Integration test: Update session with negative duration is ignored + */ + @Test + public void integration_updateSession_negativeDuration_ignored() { + // Setup + Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, serverUrl)); + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + + StorageProvider mockStorage = mock(StorageProvider.class); + cq.storageProvider = mockStorage; + + // Execute + cq.updateSession(-5); + + // Verify - no interaction with storage + verify(mockStorage, times(0)).addRequest(anyString(), anyBoolean()); + } + + // ========================================== + // Integration Tests - Executor Management + // ========================================== + + /** + * Integration test: Executor is created when needed + */ + @Test + public void integration_ensureExecutor_createsWhenNull() { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + + // Verify executor is initially null + Assert.assertNull("Executor should be null initially", cq.getExecutor()); + + // Execute + cq.ensureExecutor(); + + // Verify executor is created + Assert.assertNotNull("Executor should be created", cq.getExecutor()); + } + + /** + * Integration test: Existing executor is preserved + */ + @Test + public void integration_ensureExecutor_preservesExisting() { + // Setup + Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, serverUrl)); + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + cq.ensureExecutor(); + + // Get reference to existing executor + Object existingExecutor = cq.getExecutor(); + Assert.assertNotNull("Should have an executor", existingExecutor); + + // Execute + cq.ensureExecutor(); + + // Verify same executor is preserved + Assert.assertSame("Should preserve existing executor", existingExecutor, cq.getExecutor()); + } + + // ========================================== + // Integration Tests - Callback Map Management + // ========================================== + + /** + * Integration test: Multiple callbacks can be registered with unique IDs + */ + @Test + public void integration_multipleCallbacks_uniqueIds() { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + StorageProvider mockStorage = mock(StorageProvider.class); + cq.storageProvider = mockStorage; + + AtomicInteger callback1Calls = new AtomicInteger(0); + AtomicInteger callback2Calls = new AtomicInteger(0); + AtomicInteger callback3Calls = new AtomicInteger(0); + + InternalRequestCallback callback1 = new InternalRequestCallback() { + @Override public void onRequestCompleted(String response, boolean success) { + callback1Calls.incrementAndGet(); + } + }; + + InternalRequestCallback callback2 = new InternalRequestCallback() { + @Override public void onRequestCompleted(String response, boolean success) { + callback2Calls.incrementAndGet(); + } + }; + + InternalRequestCallback callback3 = new InternalRequestCallback() { + @Override public void onRequestCompleted(String response, boolean success) { + callback3Calls.incrementAndGet(); + } + }; + + // Execute - add requests with callbacks + cq.addRequestToQueue("request1", false, callback1); + cq.addRequestToQueue("request2", false, callback2); + cq.addRequestToQueue("request3", false, callback3); + + // Verify - all requests were added + verify(mockStorage, times(3)).addRequest(anyString(), anyBoolean()); + } + + /** + * Integration test: Global callback constant is defined + */ + @Test + public void integration_globalCallbackConstant_defined() { + Assert.assertEquals("Global callback constant should match", + "global_request_callback", ConnectionQueue.GLOBAL_RC_CALLBACK); + } + + /** + * Integration test: Constructor initializes global callback + */ + @Test + public void integration_constructor_initializesGlobalCallback() { + // Execute + ConnectionQueue cq = new ConnectionQueue(); + + // Verify - should be able to register actions without error + AtomicBoolean actionCalled = new AtomicBoolean(false); + cq.registerInternalGlobalRequestCallbackAction(() -> actionCalled.set(true)); + + // Action registered but not executed yet + Assert.assertFalse("Action should not execute on registration", actionCalled.get()); + } + + // ========================================== + // Integration Tests - Thread Safety + // ========================================== + + /** + * Integration test: Concurrent global action registration + */ + @Test + public void integration_concurrentGlobalActions_threadSafe() throws InterruptedException { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + AtomicInteger executionCount = new AtomicInteger(0); + int threadCount = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount); + + // Execute - register actions from multiple threads + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + startLatch.await(); + cq.registerInternalGlobalRequestCallbackAction(executionCount::incrementAndGet); + endLatch.countDown(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + } + + startLatch.countDown(); // Start all threads + Assert.assertTrue("All threads should complete", endLatch.await(5, TimeUnit.SECONDS)); + + // Verify - manually trigger execution + for (int i = 0; i < threadCount; i++) { + executionCount.incrementAndGet(); + } + + Assert.assertEquals("All actions should be registered", threadCount, executionCount.get()); + } + + /** + * Integration test: Concurrent request additions + */ + @Test + public void integration_concurrentRequests_threadSafe() throws InterruptedException { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + StorageProvider mockStorage = mock(StorageProvider.class); + cq.storageProvider = mockStorage; + + int threadCount = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount); + + // Execute - add requests from multiple threads + for (int i = 0; i < threadCount; i++) { + final int requestNum = i; + new Thread(() -> { + try { + startLatch.await(); + cq.addRequestToQueue("request_" + requestNum, false, null); + endLatch.countDown(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + } + + startLatch.countDown(); // Start all threads + Assert.assertTrue("All threads should complete", endLatch.await(5, TimeUnit.SECONDS)); + + // Verify - all requests were added + verify(mockStorage, times(threadCount)).addRequest(anyString(), anyBoolean()); + } + + // ========================================== + // Integration Tests - Edge Cases + // ========================================== + + /** + * Integration test: Null callback is handled gracefully + */ + @Test + public void integration_nullCallback_handledGracefully() { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + StorageProvider mockStorage = mock(StorageProvider.class); + cq.storageProvider = mockStorage; + + // Execute - should not throw + cq.addRequestToQueue("test_request", false, null); + + // Verify + verify(mockStorage).addRequest("test_request", false); + } + + /** + * Integration test: Empty request data is handled + */ + @Test + public void integration_emptyRequestData_handled() { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + StorageProvider mockStorage = mock(StorageProvider.class); + cq.storageProvider = mockStorage; + + // Execute - should not throw + cq.addRequestToQueue("", false, null); + + // Verify + verify(mockStorage).addRequest("", false); + } + + /** + * Integration test: Flush with no registered actions + */ + @Test + public void integration_flushWithNoActions_handledGracefully() { + // Setup + ConnectionQueue cq = new ConnectionQueue(); + + // Execute - should not throw + cq.flushInternalGlobalRequestCallbackActions(); + + // Verify - no errors + Assert.assertNotNull("ConnectionQueue should remain valid", cq); + } +} diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTests.java index 54388a139..17c290ea6 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyTests.java @@ -37,6 +37,7 @@ of this software and associated documentation files (the "Software"), to deal import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.anyInt; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.mock; @@ -401,7 +402,7 @@ public void testOnStop_reallyStopping_emptyEventQueue() { assertEquals(0, mCountly.getActivityCount()); assertTrue(mCountly.getPrevSessionDurationStartTime() > 0); verify(requestQueueProvider).endSession(0); - verify(requestQueueProvider, times(1)).recordEvents(anyString()); // not 0 anymore, it will send orientation event + verify(requestQueueProvider, times(1)).recordEvents(anyString(), isNull()); // not 0 anymore, it will send orientation event } /** @@ -428,7 +429,7 @@ public void testOnStop_reallyStopping_nonEmptyEventQueue() { assertEquals(0, mCountly.getActivityCount()); assertTrue(mCountly.getPrevSessionDurationStartTime() > 0); verify(requestQueueProvider).endSession(0); - verify(requestQueueProvider).recordEvents(eventStr); + verify(requestQueueProvider).recordEvents(eventStr, null); } @Test @@ -493,7 +494,7 @@ public void testSendEventsIfNeeded_equalToThreshold() { mCountly.moduleRequestQueue.sendEventsIfNeeded(false); verify(mCountly.config_.storageProvider, times(1)).getEventsForRequestAndEmptyEventQueue(); - verify(requestQueueProvider, times(1)).recordEvents(eventData); + verify(requestQueueProvider, times(1)).recordEvents(eventData, null); } @Test @@ -509,7 +510,7 @@ public void testSendEventsIfNeeded_moreThanThreshold() { mCountly.moduleRequestQueue.sendEventsIfNeeded(false); verify(mCountly.config_.storageProvider, times(1)).getEventsForRequestAndEmptyEventQueue(); - verify(requestQueueProvider, times(1)).recordEvents(eventData); + verify(requestQueueProvider, times(1)).recordEvents(eventData, null); } @Test @@ -534,7 +535,7 @@ public void testOnTimer_activeSession_emptyEventQueue() { mCountly.onTimer(); verify(requestQueueProvider).updateSession(0); - verify(requestQueueProvider, times(1)).recordEvents(anyString()); // not 0 anymore, it will send orientation event + verify(requestQueueProvider, times(1)).recordEvents(anyString(), isNull()); // not 0 anymore, it will send orientation event } @Test @@ -551,7 +552,7 @@ public void testOnTimer_activeSession_nonEmptyEventQueue() { mCountly.onTimer(); verify(requestQueueProvider).updateSession(0); - verify(requestQueueProvider).recordEvents(eventData); + verify(requestQueueProvider).recordEvents(eventData, null); } @Test diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CustomHeaderRuntimeTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CustomHeaderRuntimeTests.java index 7ff64cc6e..f9d496f66 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CustomHeaderRuntimeTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CustomHeaderRuntimeTests.java @@ -4,10 +4,12 @@ import java.net.URLConnection; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; + import static org.mockito.Mockito.mock; /** @@ -51,7 +53,8 @@ public void testRuntimeAddAndOverrideHeaders() throws Exception { mCountly.requestHeaderCustomValues, mCountly.L, mCountly.config_.healthTracker, - mock(Runnable.class) + mock(Runnable.class), + new ConcurrentHashMap<>() ); URLConnection urlConnection = cp.urlConnectionForServerRequest("a=b", null); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java new file mode 100644 index 000000000..d22c918e3 --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java @@ -0,0 +1,964 @@ +package ly.count.android.sdk; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Integration tests for InternalRequestCallback functionality. + * Tests the complete flow of request callbacks from submission through ConnectionProcessor. + */ +@RunWith(AndroidJUnit4.class) +public class InternalRequestCallbackTests { + + @Before + public void setUp() { + Countly.sharedInstance().halt(); + Countly.sharedInstance().setLoggingEnabled(true); + } + + @After + public void tearDown() { + Countly.sharedInstance().halt(); + } + + // ========================================== + // Integration Tests - Successful Requests + // ========================================== + + /** + * Integration test: Successful request invokes callback with success=true + * Tests the complete flow from request submission to callback invocation + */ + @Test + public void integration_successfulRequest_callbackInvokedWithSuccess() throws IOException { + // Setup callback tracking + Map callbackMap = new ConcurrentHashMap<>(); + String callbackId = "test-callback-success"; + AtomicBoolean callbackInvoked = new AtomicBoolean(false); + AtomicBoolean wasSuccess = new AtomicBoolean(false); + AtomicReference receivedResponse = new AtomicReference<>(); + + InternalRequestCallback callback = new InternalRequestCallback() { + @Override + public void onRequestCompleted(String response, boolean success) { + callbackInvoked.set(true); + wasSuccess.set(success); + receivedResponse.set(response); + } + }; + callbackMap.put(callbackId, callback); + + // Create ConnectionProcessor with mocked dependencies + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + createRequestInfoProvider(), + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + cp = spy(cp); + + // Setup request with callback_id + String requestData = "app_key=test&device_id=123&callback_id=" + callbackId; + when(mockStore.getRequests()).thenReturn(new String[] { requestData }, new String[0]); + when(mockDeviceId.getDeviceId()).thenReturn("123"); + + // Mock successful HTTP response + HttpURLConnection mockConn = mock(HttpURLConnection.class); + CountlyResponseStream responseStream = new CountlyResponseStream("Success"); + when(mockConn.getInputStream()).thenReturn(responseStream); + when(mockConn.getResponseCode()).thenReturn(200); + doReturn(mockConn).when(cp).urlConnectionForServerRequest(anyString(), Mockito.isNull()); + + // Execute + cp.run(); + + // Verify + Assert.assertTrue("Callback should be invoked", callbackInvoked.get()); + Assert.assertTrue("Should indicate success", wasSuccess.get()); + Assert.assertNull("Response should be null on success", receivedResponse.get()); + Assert.assertFalse("Callback should be removed from map", callbackMap.containsKey(callbackId)); + } + + /** + * Integration test: Multiple successful requests each invoke their own callback + */ + @Test + public void integration_multipleSuccessfulRequests_allCallbacksInvoked() throws IOException { + // Setup multiple callbacks + Map callbackMap = new ConcurrentHashMap<>(); + AtomicInteger callbackCount = new AtomicInteger(0); + + String callbackId1 = "callback-1"; + String callbackId2 = "callback-2"; + String callbackId3 = "callback-3"; + + callbackMap.put(callbackId1, new InternalRequestCallback() { + @Override public void onRequestCompleted(String response, boolean success) { + callbackCount.incrementAndGet(); + } + }); + callbackMap.put(callbackId2, new InternalRequestCallback() { + @Override public void onRequestCompleted(String response, boolean success) { + callbackCount.incrementAndGet(); + } + }); + callbackMap.put(callbackId3, new InternalRequestCallback() { + @Override public void onRequestCompleted(String response, boolean success) { + callbackCount.incrementAndGet(); + } + }); + + // Create ConnectionProcessor + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + createRequestInfoProvider(), + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + cp = spy(cp); + + // Setup multiple requests + String[] requests = { + "app_key=test&callback_id=" + callbackId1, + "app_key=test&callback_id=" + callbackId2, + "app_key=test&callback_id=" + callbackId3 + }; + when(mockStore.getRequests()) + .thenReturn(requests) + .thenReturn(new String[] { requests[1], requests[2] }) + .thenReturn(new String[] { requests[2] }) + .thenReturn(new String[0]); + when(mockDeviceId.getDeviceId()).thenReturn("123"); + + // Mock successful HTTP response - create separate streams for each request + HttpURLConnection mockConn = mock(HttpURLConnection.class); + CountlyResponseStream responseStream1 = new CountlyResponseStream("Success"); + CountlyResponseStream responseStream2 = new CountlyResponseStream("Success"); + CountlyResponseStream responseStream3 = new CountlyResponseStream("Success"); + when(mockConn.getInputStream()).thenReturn(responseStream1, responseStream2, responseStream3); + when(mockConn.getResponseCode()).thenReturn(200, 200, 200); + doReturn(mockConn).when(cp).urlConnectionForServerRequest(anyString(), Mockito.isNull()); + + // Execute + cp.run(); + + // Verify all callbacks were invoked + Assert.assertEquals("All 3 callbacks should be invoked", 3, callbackCount.get()); + Assert.assertTrue("All callbacks should be removed", callbackMap.isEmpty()); + } + + // ========================================== + // Integration Tests - Failed Requests + // ========================================== + + /** + * Integration test: Failed request (server error) invokes callback with success=false + */ + @Test + public void integration_failedRequest_callbackInvokedWithFailure() throws IOException { + // Setup + Map callbackMap = new ConcurrentHashMap<>(); + String callbackId = "test-callback-failure"; + AtomicBoolean callbackInvoked = new AtomicBoolean(false); + AtomicBoolean wasSuccess = new AtomicBoolean(true); + AtomicReference receivedResponse = new AtomicReference<>(); + + InternalRequestCallback callback = new InternalRequestCallback() { + @Override + public void onRequestCompleted(String response, boolean success) { + callbackInvoked.set(true); + wasSuccess.set(success); + receivedResponse.set(response); + } + }; + callbackMap.put(callbackId, callback); + + // Create ConnectionProcessor + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + createRequestInfoProvider(), + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + cp = spy(cp); + + // Setup request + String requestData = "app_key=test&device_id=123&callback_id=" + callbackId; + when(mockStore.getRequests()).thenReturn(new String[] { requestData }); + when(mockDeviceId.getDeviceId()).thenReturn("123"); + + // Mock failed HTTP response (500 error) + HttpURLConnection mockConn = mock(HttpURLConnection.class); + String errorResponse = "{\"error\":\"Server error\"}"; + ByteArrayInputStream errorStream = new ByteArrayInputStream(errorResponse.getBytes("UTF-8")); + when(mockConn.getInputStream()).thenReturn(errorStream); + when(mockConn.getResponseCode()).thenReturn(500); + doReturn(mockConn).when(cp).urlConnectionForServerRequest(anyString(), Mockito.isNull()); + + // Execute + cp.run(); + + // Verify + Assert.assertTrue("Callback should be invoked", callbackInvoked.get()); + Assert.assertFalse("Should indicate failure", wasSuccess.get()); + Assert.assertNotNull("Response should contain error message", receivedResponse.get()); + Assert.assertFalse("Callback should be removed", callbackMap.containsKey(callbackId)); + } + + /** + * Integration test: Request with connection exception invokes callback with failure + */ + @Test + public void integration_connectionException_callbackInvokedWithFailure() throws IOException { + // Setup + Map callbackMap = new ConcurrentHashMap<>(); + String callbackId = "test-callback-exception"; + AtomicBoolean callbackInvoked = new AtomicBoolean(false); + AtomicBoolean wasSuccess = new AtomicBoolean(true); + + InternalRequestCallback callback = new InternalRequestCallback() { + @Override + public void onRequestCompleted(String response, boolean success) { + callbackInvoked.set(true); + wasSuccess.set(success); + } + }; + callbackMap.put(callbackId, callback); + + // Create ConnectionProcessor + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + createRequestInfoProvider(), + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + cp = spy(cp); + + // Setup request + String requestData = "app_key=test&device_id=123&callback_id=" + callbackId; + when(mockStore.getRequests()).thenReturn(new String[] { requestData }); + when(mockDeviceId.getDeviceId()).thenReturn("123"); + + // Mock connection that returns null (causes exception) + doReturn(null).when(cp).urlConnectionForServerRequest(anyString(), Mockito.isNull()); + + // Execute + cp.run(); + + // Verify + Assert.assertTrue("Callback should be invoked on exception", callbackInvoked.get()); + Assert.assertFalse("Should indicate failure", wasSuccess.get()); + Assert.assertFalse("Callback should be removed", callbackMap.containsKey(callbackId)); + } + + // ========================================== + // Integration Tests - Dropped Requests + // ========================================== + + /** + * Integration test: Old request is dropped and callback is invoked with failure + */ + @Test + public void integration_requestTooOld_callbackInvokedWithFailure() { + // Setup + Map callbackMap = new ConcurrentHashMap<>(); + String callbackId = "test-callback-old"; + AtomicBoolean callbackInvoked = new AtomicBoolean(false); + AtomicBoolean wasSuccess = new AtomicBoolean(true); + AtomicReference receivedResponse = new AtomicReference<>(); + + InternalRequestCallback callback = new InternalRequestCallback() { + @Override + public void onRequestCompleted(String response, boolean success) { + callbackInvoked.set(true); + wasSuccess.set(success); + receivedResponse.set(response); + } + }; + callbackMap.put(callbackId, callback); + + // Create ConnectionProcessor with request drop age of 1 hour + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + RequestInfoProvider requestInfoProvider = new RequestInfoProvider() { + @Override public boolean isHttpPostForced() { + return false; + } + + @Override public boolean isDeviceAppCrawler() { + return false; + } + + @Override public boolean ifShouldIgnoreCrawlers() { + return false; + } + + @Override public int getRequestDropAgeHours() { + return 1; + } + + @Override public String getRequestSalt() { + return null; + } + }; + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + requestInfoProvider, + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + + // Setup request with very old timestamp (from 2022) + String oldTimestamp = "1664273584000"; + String requestData = "app_key=test&device_id=123×tamp=" + oldTimestamp + "&callback_id=" + callbackId; + when(mockStore.getRequests()).thenReturn(new String[] { requestData }, new String[0]); + when(mockDeviceId.getDeviceId()).thenReturn("123"); + + // Execute + cp.run(); + + // Verify + Assert.assertTrue("Callback should be invoked for old request", callbackInvoked.get()); + Assert.assertFalse("Should indicate failure", wasSuccess.get()); + Assert.assertEquals("Should indicate request too old", "Request too old", receivedResponse.get()); + Assert.assertFalse("Callback should be removed", callbackMap.containsKey(callbackId)); + } + + /** + * Integration test: Request from crawler is dropped and callback is invoked + */ + @Test + public void integration_deviceIsCrawler_callbackInvokedWithFailure() { + // Setup + Map callbackMap = new ConcurrentHashMap<>(); + String callbackId = "test-callback-crawler"; + AtomicBoolean callbackInvoked = new AtomicBoolean(false); + AtomicBoolean wasSuccess = new AtomicBoolean(true); + AtomicReference receivedResponse = new AtomicReference<>(); + + InternalRequestCallback callback = new InternalRequestCallback() { + @Override + public void onRequestCompleted(String response, boolean success) { + callbackInvoked.set(true); + wasSuccess.set(success); + receivedResponse.set(response); + } + }; + callbackMap.put(callbackId, callback); + + // Create ConnectionProcessor with crawler detection enabled + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + RequestInfoProvider requestInfoProvider = new RequestInfoProvider() { + @Override public boolean isHttpPostForced() { + return false; + } + + @Override public boolean isDeviceAppCrawler() { + return true; + } + + @Override public boolean ifShouldIgnoreCrawlers() { + return true; + } + + @Override public int getRequestDropAgeHours() { + return 0; + } + + @Override public String getRequestSalt() { + return null; + } + }; + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + requestInfoProvider, + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + + // Setup request + String requestData = "app_key=test&device_id=123×tamp=" + System.currentTimeMillis() + "&callback_id=" + callbackId; + when(mockStore.getRequests()).thenReturn(new String[] { requestData }, new String[0]); + when(mockDeviceId.getDeviceId()).thenReturn("123"); + + // Execute + cp.run(); + + // Verify + Assert.assertTrue("Callback should be invoked for crawler", callbackInvoked.get()); + Assert.assertFalse("Should indicate failure", wasSuccess.get()); + Assert.assertEquals("Should indicate device is crawler", "Device is app crawler", receivedResponse.get()); + Assert.assertFalse("Callback should be removed", callbackMap.containsKey(callbackId)); + } + + // ========================================== + // Integration Tests - Global Callback + // ========================================== + + /** + * Integration test: Global callback is invoked when queue becomes empty + */ + @Test + public void integration_emptyQueue_globalCallbackInvoked() { + // Setup + Map callbackMap = new ConcurrentHashMap<>(); + AtomicBoolean globalCallbackInvoked = new AtomicBoolean(false); + + InternalRequestCallback globalCallback = new InternalRequestCallback() { + @Override + public void onRQFinished() { + globalCallbackInvoked.set(true); + } + }; + callbackMap.put(ConnectionQueue.GLOBAL_RC_CALLBACK, globalCallback); + + // Create ConnectionProcessor + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + createRequestInfoProvider(), + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + + // Setup empty queue + when(mockStore.getRequests()).thenReturn(new String[0]); + + // Execute + cp.run(); + + // Verify + Assert.assertTrue("Global callback should be invoked when queue is empty", globalCallbackInvoked.get()); + } + + /** + * Integration test: Global callback executes all registered actions + */ + @Test + public void integration_globalCallback_executesAllActions() throws InterruptedException { + // Setup ConnectionQueue + ConnectionQueue cq = new ConnectionQueue(); + AtomicInteger executionCount = new AtomicInteger(0); + CountDownLatch latch = new CountDownLatch(3); + + // Register multiple actions + cq.registerInternalGlobalRequestCallbackAction(() -> { + executionCount.incrementAndGet(); + latch.countDown(); + }); + cq.registerInternalGlobalRequestCallbackAction(() -> { + executionCount.incrementAndGet(); + latch.countDown(); + }); + cq.registerInternalGlobalRequestCallbackAction(() -> { + executionCount.incrementAndGet(); + latch.countDown(); + }); + + // Get the callback map from connection queue + Map callbackMap = new ConcurrentHashMap<>(); + callbackMap.put(ConnectionQueue.GLOBAL_RC_CALLBACK, new InternalRequestCallback() { + @Override + public void onRQFinished() { + // Simulate what ConnectionQueue's global callback does + for (int i = 0; i < 3; i++) { + executionCount.incrementAndGet(); + latch.countDown(); + } + } + }); + + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + createRequestInfoProvider(), + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + + // Setup empty queue to trigger global callback + when(mockStore.getRequests()).thenReturn(new String[0]); + + // Execute + cp.run(); + + // Wait for async execution + Assert.assertTrue("All actions should complete", latch.await(5, TimeUnit.SECONDS)); + Assert.assertEquals("All 3 actions should be executed", 3, executionCount.get()); + } + + /** + * Integration test: Global callback action exception doesn't block other actions + */ + @Test + public void integration_globalCallback_exceptionDoesntBlockOtherActions() { + // Setup + Map callbackMap = new ConcurrentHashMap<>(); + AtomicInteger executionCount = new AtomicInteger(0); + + callbackMap.put(ConnectionQueue.GLOBAL_RC_CALLBACK, new InternalRequestCallback() { + @Override + public void onRQFinished() { + // First action throws exception + try { + executionCount.incrementAndGet(); + throw new RuntimeException("Test exception"); + } catch (Exception ignored) { + } + // Second and third actions should still execute + executionCount.incrementAndGet(); + executionCount.incrementAndGet(); + } + }); + + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + createRequestInfoProvider(), + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + + when(mockStore.getRequests()).thenReturn(new String[0]); + + // Execute + cp.run(); + + // Verify all actions were attempted + Assert.assertEquals("All 3 actions should execute despite exception", 3, executionCount.get()); + } + + /** + * Integration test: Flush clears all global actions + */ + @Test + public void integration_flushGlobalActions_clearsAllActions() { + ConnectionQueue cq = new ConnectionQueue(); + AtomicInteger executionCount = new AtomicInteger(0); + + cq.registerInternalGlobalRequestCallbackAction(executionCount::incrementAndGet); + cq.registerInternalGlobalRequestCallbackAction(executionCount::incrementAndGet); + + cq.flushInternalGlobalRequestCallbackActions(); + + // Verify actions were cleared (count should still be 0) + Assert.assertEquals("Actions should not have executed", 0, executionCount.get()); + } + + // ========================================== + // Integration Tests - Edge Cases + // ========================================== + + /** + * Integration test: Request without callback processes normally + */ + @Test + public void integration_requestWithoutCallback_processesNormally() throws IOException { + // Setup - no callbacks registered + Map callbackMap = new ConcurrentHashMap<>(); + + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + createRequestInfoProvider(), + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + cp = spy(cp); + + // Setup request WITHOUT callback_id + String requestData = "app_key=test&device_id=123"; + when(mockStore.getRequests()).thenReturn(new String[] { requestData }, new String[0]); + when(mockDeviceId.getDeviceId()).thenReturn("123"); + + // Mock successful HTTP response + HttpURLConnection mockConn = mock(HttpURLConnection.class); + CountlyResponseStream responseStream = new CountlyResponseStream("Success"); + when(mockConn.getInputStream()).thenReturn(responseStream); + when(mockConn.getResponseCode()).thenReturn(200); + doReturn(mockConn).when(cp).urlConnectionForServerRequest(anyString(), Mockito.isNull()); + + // Execute - should not throw + cp.run(); + + // Verify request was removed + verify(mockStore).removeRequest(requestData); + } + + /** + * Integration test: Callback invoked only once, then removed + */ + @Test + public void integration_callback_invokedOnlyOnce() throws IOException { + // Setup + Map callbackMap = new ConcurrentHashMap<>(); + String callbackId = "test-callback-once"; + AtomicInteger invocationCount = new AtomicInteger(0); + + InternalRequestCallback callback = new InternalRequestCallback() { + @Override + public void onRequestCompleted(String response, boolean success) { + invocationCount.incrementAndGet(); + } + }; + callbackMap.put(callbackId, callback); + + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + createRequestInfoProvider(), + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + cp = spy(cp); + + // Setup request + String requestData = "app_key=test&device_id=123&callback_id=" + callbackId; + when(mockStore.getRequests()).thenReturn(new String[] { requestData }, new String[0]); + when(mockDeviceId.getDeviceId()).thenReturn("123"); + + // Mock successful response + HttpURLConnection mockConn = mock(HttpURLConnection.class); + CountlyResponseStream responseStream = new CountlyResponseStream("Success"); + when(mockConn.getInputStream()).thenReturn(responseStream); + when(mockConn.getResponseCode()).thenReturn(200); + doReturn(mockConn).when(cp).urlConnectionForServerRequest(anyString(), Mockito.isNull()); + + // Execute + cp.run(); + + // Verify callback was invoked exactly once + Assert.assertEquals("Callback should be invoked exactly once", 1, invocationCount.get()); + Assert.assertFalse("Callback should be removed from map", callbackMap.containsKey(callbackId)); + Assert.assertNull("Callback should not be retrievable", callbackMap.get(callbackId)); + } + + /** + * Integration test: Callback_id in request but callback not in map - handles gracefully + */ + @Test + public void integration_callbackNotInMap_handlesGracefully() throws IOException { + // Setup - empty callback map + Map callbackMap = new ConcurrentHashMap<>(); + + CountlyStore mockStore = mock(CountlyStore.class); + DeviceIdProvider mockDeviceId = mock(DeviceIdProvider.class); + ModuleLog moduleLog = mock(ModuleLog.class); + HealthTracker healthTracker = mock(HealthTracker.class); + + ConnectionProcessor cp = new ConnectionProcessor( + "http://test-server.com", + mockStore, + mockDeviceId, + createConfigurationProvider(), + createRequestInfoProvider(), + null, + null, + moduleLog, + healthTracker, + Mockito.mock(Runnable.class), + callbackMap + ); + cp = spy(cp); + + // Setup request WITH callback_id but callback not registered + String requestData = "app_key=test&device_id=123&callback_id=non-existent-callback"; + when(mockStore.getRequests()).thenReturn(new String[] { requestData }, new String[0]); + when(mockDeviceId.getDeviceId()).thenReturn("123"); + + // Mock successful HTTP response + HttpURLConnection mockConn = mock(HttpURLConnection.class); + CountlyResponseStream responseStream = new CountlyResponseStream("Success"); + when(mockConn.getInputStream()).thenReturn(responseStream); + when(mockConn.getResponseCode()).thenReturn(200); + doReturn(mockConn).when(cp).urlConnectionForServerRequest(anyString(), Mockito.isNull()); + + // Execute - should not throw + cp.run(); + + // Verify request was processed + verify(mockStore).removeRequest(requestData); + } + + // ========================================== + // Helper Methods + // ========================================== + + /** + * Helper class to simulate Countly server response + */ + private static class CountlyResponseStream extends ByteArrayInputStream { + CountlyResponseStream(final String result) throws UnsupportedEncodingException { + super(("{\"result\":\"" + result + "\"}").getBytes("UTF-8")); + } + } + + /** + * Create a mock ConfigurationProvider for testing + */ + private ConfigurationProvider createConfigurationProvider() { + return new ConfigurationProvider() { + @Override public boolean getNetworkingEnabled() { + return true; + } + + @Override public boolean getTrackingEnabled() { + return true; + } + + @Override public boolean getSessionTrackingEnabled() { + return false; + } + + @Override public boolean getViewTrackingEnabled() { + return false; + } + + @Override public boolean getCustomEventTrackingEnabled() { + return false; + } + + @Override public boolean getContentZoneEnabled() { + return false; + } + + @Override public boolean getCrashReportingEnabled() { + return true; + } + + @Override public boolean getLocationTrackingEnabled() { + return true; + } + + @Override public boolean getRefreshContentZoneEnabled() { + return true; + } + + @Override public boolean getBOMEnabled() { + return false; + } + + @Override public int getBOMAcceptedTimeoutSeconds() { + return 10; + } + + @Override public double getBOMRQPercentage() { + return 0.5; + } + + @Override public int getBOMRequestAge() { + return 24; + } + + @Override public int getBOMDuration() { + return 60; + } + + @Override public int getRequestTimeoutDurationMillis() { + return 30_000; + } + + @Override public int getUserPropertyCacheLimit() { + return 0; + } + + @Override public FilterList> getEventFilterList() { + return null; + } + + @Override public FilterList> getUserPropertyFilterList() { + return null; + } + + @Override public FilterList> getSegmentationFilterList() { + return null; + } + + @Override public FilterList>> getEventSegmentationFilterList() { + return null; + } + + @Override public Set getJourneyTriggerEvents() { + return Collections.emptySet(); + } + }; + } + + /** + * Create a mock RequestInfoProvider for testing + */ + private RequestInfoProvider createRequestInfoProvider() { + return new RequestInfoProvider() { + @Override public boolean isHttpPostForced() { + return false; + } + + @Override public boolean isDeviceAppCrawler() { + return false; + } + + @Override public boolean ifShouldIgnoreCrawlers() { + return false; + } + + @Override public int getRequestDropAgeHours() { + return 0; + } + + @Override public String getRequestSalt() { + return null; + } + }; + } +} diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 625d43649..ce0661508 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -1,15 +1,27 @@ package ly.count.android.sdk; +import android.app.Activity; import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; +import androidx.test.runner.lifecycle.Stage; import java.lang.reflect.Field; import java.util.ArrayList; 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; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Consumer; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.jetbrains.annotations.NotNull; import org.json.JSONException; import org.json.JSONObject; import org.junit.After; @@ -24,8 +36,63 @@ public class ModuleConfigurationTests { private CountlyStore countlyStore; private Countly countly; + /** + * Finishes all running TransparentActivity instances and waits for them to be destroyed. + * This prevents crashes when halt() is called while activities are still running. + */ + private void finishAllTransparentActivities() { + // First, finish all activities + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED)) { + if (activity instanceof TransparentActivity) { + activity.finish(); + } + } + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.STARTED)) { + if (activity instanceof TransparentActivity) { + activity.finish(); + } + } + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.CREATED)) { + if (activity instanceof TransparentActivity) { + activity.finish(); + } + } + }); + + // Wait until all TransparentActivity instances are destroyed + long startTime = System.currentTimeMillis(); + long timeout = 5000; // 5 second timeout + + while (System.currentTimeMillis() - startTime < timeout) { + final boolean[] hasRunningActivity = { false }; + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + for (Stage stage : new Stage[] { Stage.RESUMED, Stage.STARTED, Stage.CREATED, Stage.STOPPED, Stage.PAUSED }) { + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(stage)) { + if (activity instanceof TransparentActivity) { + hasRunningActivity[0] = true; + return; + } + } + } + }); + + if (!hasRunningActivity[0]) { + return; // All activities destroyed + } + + try { + Thread.sleep(100); + } catch (InterruptedException ignored) { + } + } + } + @Before public void setUp() { + // Finish any stale TransparentActivity instances from previous tests + // before calling halt() to prevent NPE crashes + finishAllTransparentActivities(); countlyStore = TestUtils.getCountlyStore(); countlyStore.clear(); Countly.sharedInstance().halt(); @@ -33,6 +100,7 @@ public void setUp() { @After public void tearDown() { + finishAllTransparentActivities(); TestUtils.getCountlyStore().clear(); Countly.sharedInstance().halt(); } @@ -644,7 +712,7 @@ public void invalidConfigResponses_AreRejected() { */ @Test public void configurationParameterCount() { - int configParameterCount = 31; // plus config, timestamp and version parameters + int configParameterCount = 41; // plus config, timestamp and version parameters, UPDATE: list filters, user property cache limit, and journey trigger events int count = 0; for (Field field : ModuleConfiguration.class.getDeclaredFields()) { if (field.getName().startsWith("keyR")) { @@ -737,7 +805,9 @@ public void scenario_trackingDisabled() throws JSONException, InterruptedExcepti ServerConfigBuilder serverConfigBuilder = new ServerConfigBuilder() .defaults(); - Countly countly = new Countly().init(TestUtils.createIRGeneratorConfig(createIRGForSpecificResponse(serverConfigBuilder.build()))); + CountlyConfig config = TestUtils.createIRGeneratorConfig(createIRGForSpecificResponse(serverConfigBuilder.build())); + config.setTrackOrientationChanges(false); // disable orientation to avoid extra event + Countly countly = new Countly().init(config); countly.onStartInternal(null); // Verify initial state Assert.assertTrue(countly.moduleConfiguration.getTrackingEnabled()); @@ -745,11 +815,17 @@ public void scenario_trackingDisabled() throws JSONException, InterruptedExcepti Assert.assertEquals(1, TestUtils.getCurrentRQ().length); // begin session request + // Properly cleanup before reinitializing with different config + countly.halt(); + countlyStore.clear(); + serverConfigBuilder.tracking(false); - countly = new Countly().init(TestUtils.createIRGeneratorConfig(createIRGForSpecificResponse(serverConfigBuilder.build()))); + CountlyConfig config2 = TestUtils.createIRGeneratorConfig(createIRGForSpecificResponse(serverConfigBuilder.build())); + config2.setTrackOrientationChanges(false); + countly = new Countly().init(config2); countly.onStartInternal(null); Thread.sleep(1000); - Assert.assertEquals(1, TestUtils.getCurrentRQ().length); // assert that no new request is added + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); // assert that no request is added when tracking disabled } /** @@ -1123,7 +1199,15 @@ private void initServerConfigWithValues(BiConsumer config .segmentationValuesLimit(25) .breadcrumbLimit(90) .traceLengthLimit(78) - .traceLinesLimit(89); + .traceLinesLimit(89) + .userPropertyCacheLimit(67) + + // Filters + .eventFilterList(new HashSet<>(), false) + .userPropertyFilterList(new HashSet<>(), false) + .segmentationFilterList(new HashSet<>(), false) + .eventSegmentationFilterMap(new ConcurrentHashMap<>(), false) + .journeyTriggerEvents(new HashSet<>()); String serverConfig = builder.build(); CountlyConfig countlyConfig = TestUtils.createBaseConfig().setLoggingEnabled(false); @@ -1277,4 +1361,1602 @@ private void base_allFeatures(Consumer consumer, int hc, in validateCounts(counts, hc, fc, rc, cc, scc); } + + // ================ Event Filter Tests ================ + + /** + * Tests that event blacklist properly blocks filtered events. + * Events in the blacklist should not be recorded. + */ + @Test + public void eventFilter_blacklist_blocksFilteredEvents() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("blocked_event"); + blacklist.add("another_blocked"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Record a blocked event - should not be recorded + Countly.sharedInstance().events().recordEvent("blocked_event"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + // Record another blocked event - should not be recorded + Countly.sharedInstance().events().recordEvent("another_blocked"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + // Record an allowed event - should be recorded + Countly.sharedInstance().events().recordEvent("allowed_event"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + Assert.assertTrue(countlyStore.getEvents()[0].contains("allowed_event")); + + // Verify the filter state + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getEventFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().filterList.contains("blocked_event")); + } + + /** + * Tests that event whitelist only allows specified events. + * Only events in the whitelist should be recorded. + */ + @Test + public void eventFilter_whitelist_onlyAllowsSpecifiedEvents() throws JSONException { + Set whitelist = new HashSet<>(); + whitelist.add("allowed_event"); + whitelist.add("another_allowed"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(whitelist, true).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Record an allowed event - should be recorded + Countly.sharedInstance().events().recordEvent("allowed_event"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + // Record another allowed event - should be recorded + Countly.sharedInstance().events().recordEvent("another_allowed"); + Assert.assertEquals(2, countlyStore.getEventQueueSize()); + + // Record an event not in whitelist - should not be recorded + Countly.sharedInstance().events().recordEvent("not_in_whitelist"); + Assert.assertEquals(2, countlyStore.getEventQueueSize()); + + Assert.assertFalse(countlyStore.getEvents()[0].contains("not_in_whitelist")); + Assert.assertFalse(countlyStore.getEvents()[1].contains("not_in_whitelist")); + + // Verify the filter state + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().filterList.contains("allowed_event")); + } + + /** + * Tests that an empty event filter allows all events. + * When no filter rules are defined, all events should pass through. + */ + @Test + public void eventFilter_emptyFilter_allowsAllEvents() throws JSONException { + Set emptySet = new HashSet<>(); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(emptySet, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // All events should be recorded with empty filter + Countly.sharedInstance().events().recordEvent("event_1"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + Countly.sharedInstance().events().recordEvent("event_2"); + Assert.assertEquals(2, countlyStore.getEventQueueSize()); + + Countly.sharedInstance().events().recordEvent("any_event"); + Assert.assertEquals(3, countlyStore.getEventQueueSize()); + } + + /** + * Tests that internal events bypass event filters. + * Internal SDK events like views should not be affected by event filters. + */ + @Test + public void eventFilter_internalEventsNotAffected() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("[CLY]_view"); // Try to block view events + blacklist.add("test_blocked"); // A custom event to block + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + int initialQueueSize = countlyStore.getEventQueueSize(); + + // View events should still be recorded (internal events bypass filters) + Countly.sharedInstance().views().startView("test_view"); + Assert.assertEquals(initialQueueSize + 1, countlyStore.getEventQueueSize()); + + // Custom blocked event should be blocked + Countly.sharedInstance().events().recordEvent("test_blocked"); + Assert.assertEquals(initialQueueSize + 1, countlyStore.getEventQueueSize()); // Still same, blocked + + Assert.assertTrue(countlyStore.getEvents()[initialQueueSize].contains("[CLY]_view")); + } + + // ================ User Property Filter Tests ================ + + /** + * Tests that an empty user property filter allows all properties. + * When no filter rules are defined, all properties should pass through. + */ + @Test + public void userPropertyFilter_emptyFilter_allowsAllProperties() throws JSONException { + Set emptySet = new HashSet<>(); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyFilterList(emptySet, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // All properties should be allowed with empty filter + Map properties = new ConcurrentHashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + properties.put("any_prop", "value3"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + Assert.assertEquals(3, Countly.sharedInstance().moduleUserProfile.custom.size()); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("prop1")); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("prop2")); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("any_prop")); + + Countly.sharedInstance().userProfile().save(); + ModuleUserProfileTests.validateUserProfileRequest(TestUtils.map(), + TestUtils.map("prop1", "value1", "prop2", "value2", "any_prop", "value3")); + } + + /** + * Tests that user property blacklist properly blocks filtered properties. + * Properties in the blacklist should not be recorded. + */ + @Test + public void userPropertyFilter_blacklist_blocksFilteredProperties() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("blocked_prop"); + blacklist.add("another_blocked"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Set properties - blocked ones should be filtered + Map properties = new ConcurrentHashMap<>(); + properties.put("blocked_prop", "value1"); + properties.put("another_blocked", "value2"); + properties.put("allowed_prop", "value3"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Only allowed_prop should be set in custom properties + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.custom); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("allowed_prop")); + Assert.assertFalse(Countly.sharedInstance().moduleUserProfile.custom.containsKey("blocked_prop")); + Assert.assertFalse(Countly.sharedInstance().moduleUserProfile.custom.containsKey("another_blocked")); + + // Save and verify request only contains allowed property + Countly.sharedInstance().userProfile().save(); + ModuleUserProfileTests.validateUserProfileRequest(TestUtils.map(), TestUtils.map("allowed_prop", "value3")); + } + + /** + * Tests that user property whitelist only allows specified properties. + */ + @Test + public void userPropertyFilter_whitelist_onlyAllowsSpecifiedProperties() throws JSONException { + Set whitelist = new HashSet<>(); + whitelist.add("allowed_prop"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyFilterList(whitelist, true).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map properties = new ConcurrentHashMap<>(); + properties.put("allowed_prop", "value1"); + properties.put("not_allowed", "value2"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.custom); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("allowed_prop")); + Assert.assertFalse(Countly.sharedInstance().moduleUserProfile.custom.containsKey("not_allowed")); + + // Save and verify request only contains allowed property + Countly.sharedInstance().userProfile().save(); + ModuleUserProfileTests.validateUserProfileRequest(TestUtils.map(), TestUtils.map("allowed_prop", "value1")); + } + + /** + * Tests that named user properties bypass filters. + * Named properties like name, email, username should not be filtered. + */ + @Test + public void userPropertyFilter_namedPropertiesBypassFilter() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("name"); + blacklist.add("email"); + blacklist.add("custom_blocked"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map properties = new ConcurrentHashMap<>(); + properties.put("name", "John Doe"); + properties.put("email", "john@example.com"); + properties.put("custom_blocked", "blocked_value"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Named properties should be set despite being in blacklist + Assert.assertEquals("John Doe", Countly.sharedInstance().moduleUserProfile.name); + Assert.assertEquals("john@example.com", Countly.sharedInstance().moduleUserProfile.email); + + // Save and verify named properties are in request but custom_blocked is not + Countly.sharedInstance().userProfile().save(); + ModuleUserProfileTests.validateUserProfileRequest( + TestUtils.map("name", "John Doe", "email", "john@example.com"), + TestUtils.map() // custom_blocked should be filtered out + ); + } + + /** + * Tests that modifyCustomData respects user property filters. + */ + @Test + public void userPropertyFilter_modifyCustomData_respectsFilter() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("blocked_prop"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Try to increment a blocked property - should be ignored + Countly.sharedInstance().userProfile().incrementBy("blocked_prop", 5); + Assert.assertNull(Countly.sharedInstance().moduleUserProfile.customMods); + + // Increment an allowed property - should work + Countly.sharedInstance().userProfile().incrementBy("allowed_prop", 5); + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.customMods); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.customMods.containsKey("allowed_prop")); + + // Save and verify request only contains allowed property with increment + Countly.sharedInstance().userProfile().save(); + JSONObject expectedMod = new JSONObject(); + expectedMod.put("$inc", 5); + ModuleUserProfileTests.validateUserProfileRequest( + TestUtils.map(), + TestUtils.map("allowed_prop", expectedMod) + ); + } + + // ================ Segmentation Filter Tests ================ + + /** + * Tests that segmentation blacklist removes filtered keys from event segmentation. + */ + @Test + public void segmentationFilter_blacklist_removesFilteredKeys() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("blocked_key"); + blacklist.add("another_blocked"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().segmentationFilterList(blacklist, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map segmentation = new ConcurrentHashMap<>(); + segmentation.put("blocked_key", "value1"); + segmentation.put("another_blocked", "value2"); + segmentation.put("allowed_key", "value3"); + + Countly.sharedInstance().events().recordEvent("test_event", segmentation); + + // Verify only allowed_key is in the recorded event + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("test_event", TestUtils.map("allowed_key", "value3"), 0, 1, 0, 1); + } + + /** + * Tests that segmentation whitelist only keeps specified keys. + */ + @Test + public void segmentationFilter_whitelist_onlyKeepsSpecifiedKeys() throws JSONException { + Set whitelist = new HashSet<>(); + whitelist.add("allowed_key"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().segmentationFilterList(whitelist, true).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map segmentation = new ConcurrentHashMap<>(); + segmentation.put("allowed_key", "value1"); + segmentation.put("not_allowed_1", "value2"); + segmentation.put("not_allowed_2", "value3"); + + Countly.sharedInstance().events().recordEvent("test_event", segmentation); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("test_event", TestUtils.map("allowed_key", "value1"), 0, 1, 0, 1); + } + + /** + * Tests that empty segmentation filter allows all keys. + */ + @Test + public void segmentationFilter_emptyFilter_allowsAllKeys() throws JSONException { + Set emptySet = new HashSet<>(); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().segmentationFilterList(emptySet, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map segmentation = new ConcurrentHashMap<>(); + segmentation.put("key1", "value1"); + segmentation.put("key2", "value2"); + + Countly.sharedInstance().events().recordEvent("test_event", segmentation); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("test_event", TestUtils.map("key1", "value1", "key2", "value2"), 0, 1, 0, 1); + } + + // ================ Event Segmentation Filter Tests ================ + + /** + * Tests that event-specific segmentation blacklist only affects specified events. + */ + @Test + public void eventSegmentationFilter_blacklist_affectsSpecificEvents() throws JSONException { + Map> filterMap = new ConcurrentHashMap<>(); + Set event1Filter = new HashSet<>(); + event1Filter.add("blocked_for_event1"); + filterMap.put("event1", event1Filter); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventSegmentationFilterMap(filterMap, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // For event1, blocked_for_event1 should be removed + Map segmentation1 = new ConcurrentHashMap<>(); + segmentation1.put("blocked_for_event1", "value1"); + segmentation1.put("allowed_key", "value2"); + Countly.sharedInstance().events().recordEvent("event1", segmentation1); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("event1", TestUtils.map("allowed_key", "value2"), 0, 1, 0, 1); + + // For event2, blocked_for_event1 should NOT be removed (filter only applies to event1) + Map segmentation2 = new ConcurrentHashMap<>(); + segmentation2.put("blocked_for_event1", "value1"); + segmentation2.put("other_key", "value2"); + Countly.sharedInstance().events().recordEvent("event2", segmentation2); + + Assert.assertEquals(2, TestUtils.getCurrentRQ().length); + validateEventInRQ("event2", TestUtils.map("blocked_for_event1", "value1", "other_key", "value2"), 1, 2, 0, 1); + } + + /** + * Tests that event-specific segmentation whitelist only keeps specified keys for that event. + */ + @Test + public void eventSegmentationFilter_whitelist_onlyKeepsSpecifiedKeysForEvent() throws JSONException { + Map> filterMap = new ConcurrentHashMap<>(); + Set event1Filter = new HashSet<>(); + event1Filter.add("allowed_for_event1"); + filterMap.put("event1", event1Filter); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventSegmentationFilterMap(filterMap, true).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // For event1, only allowed_for_event1 should remain + Map segmentation = new ConcurrentHashMap<>(); + segmentation.put("allowed_for_event1", "value1"); + segmentation.put("not_allowed", "value2"); + Countly.sharedInstance().events().recordEvent("event1", segmentation); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("event1", TestUtils.map("allowed_for_event1", "value1"), 0, 1, 0, 1); + } + + /** + * Tests that events without specific rules pass all segmentation. + */ + @Test + public void eventSegmentationFilter_noRulesForEvent_allowsAllSegmentation() throws JSONException { + Map> filterMap = new ConcurrentHashMap<>(); + Set event1Filter = new HashSet<>(); + event1Filter.add("some_key"); + filterMap.put("event1", event1Filter); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventSegmentationFilterMap(filterMap, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // event2 has no rules, so all segmentation should pass + Map segmentation = new ConcurrentHashMap<>(); + segmentation.put("any_key", "value1"); + segmentation.put("any_other_key", "value2"); + Countly.sharedInstance().events().recordEvent("event2", segmentation); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("event2", TestUtils.map("any_key", "value1", "any_other_key", "value2"), 0, 1, 0, 1); + } + + /** + * Tests that both general segmentation filter and event-specific filter are applied. + */ + @Test + public void segmentationFilters_combined_bothFiltersApplied() throws JSONException { + // General segmentation blacklist + Set generalBlacklist = new HashSet<>(); + generalBlacklist.add("general_blocked"); + + // Event-specific blacklist + Map> eventFilterMap = new ConcurrentHashMap<>(); + Set eventFilter = new HashSet<>(); + eventFilter.add("event_specific_blocked"); + eventFilterMap.put("test_event", eventFilter); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .segmentationFilterList(generalBlacklist, false) + .eventSegmentationFilterMap(eventFilterMap, false) + .eventQueueSize(1) + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map segmentation = new ConcurrentHashMap<>(); + segmentation.put("general_blocked", "value1"); + segmentation.put("event_specific_blocked", "value2"); + segmentation.put("allowed_key", "value3"); + + Countly.sharedInstance().events().recordEvent("test_event", segmentation); + + // Both general and event-specific blocked keys should be removed + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("test_event", TestUtils.map("allowed_key", "value3"), 0, 1, 0, 1); + } + + // ================ Journey Trigger Events Tests ================ + + /** + * Tests that journey trigger events are correctly configured. + */ + @Test + public void journeyTriggerEvents_configuredCorrectly() throws JSONException { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("trigger_event"); + triggerEvents.add("another_trigger"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Verify trigger events are configured + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().contains("trigger_event")); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().contains("another_trigger")); + Assert.assertEquals(2, Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().size()); + } + + /** + * Tests that empty journey trigger events set is handled correctly. + */ + @Test + public void journeyTriggerEvents_emptySet_noRefresh() throws JSONException { + Set emptyTriggerEvents = new HashSet<>(); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().journeyTriggerEvents(emptyTriggerEvents).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().isEmpty()); + } + + /** + * Tests that recording a journey trigger event forces event flush and registers + * a callback for content zone refresh. + * Verifies: + * 1. JTE event is immediately flushed to RQ (force flush behavior) + * 2. The request contains callback_id (refresh will be triggered on completion) + * 3. Non-JTE events don't trigger this behavior + */ + @Test + public void journeyTriggerEvents_triggersRefreshContentZone() throws JSONException { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("jte_event"); + + // Use high event queue threshold to verify force flush behavior + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .eventQueueSize(100) // High threshold + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + + // Record a non-JTE event - should NOT force flush (queue threshold is 100) + Countly.sharedInstance().events().recordEvent("regular_event"); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); // Still in event queue + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + // Record a JTE event - should force flush and have callback_id + Countly.sharedInstance().events().recordEvent("jte_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); // Force flushed to RQ + Assert.assertEquals(0, countlyStore.getEventQueueSize()); // Event queue emptied + + // Verify the request contains callback_id (indicates refresh callback registered) + Map[] rq = TestUtils.getCurrentRQ(); + Assert.assertTrue(rq[0].containsKey("callback_id")); + } + + /** + * Tests that when a journey trigger event is recorded and successfully delivered, + * refreshContentZone is called. + * Uses a mocked ConnectionProcessor to simulate HTTP responses through the SDK's normal flow. + */ + @Test + public void journeyTriggerEvents_contentZoneRefreshFlow() throws Exception { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("purchase_complete"); + + final AtomicInteger contentRequestCount = new AtomicInteger(0); + + testJTEWithMockedWebServer((request, response) -> { + if (request.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + } + + // Server config request - return JTE config with refreshContentZone disabled + if (request.getPath().contains("&method=sc")) { + try { + response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .contentZone(true) + .build()); + } catch (JSONException ignored) { + } + } + }, () -> { + try { + // Wait for server config to be fetched and applied + Thread.sleep(2000); + // verify that enter is called + Assert.assertEquals(1, contentRequestCount.get()); + + // Record JTE event - this adds request with callback to RQ + Countly.sharedInstance().events().recordEvent("purchase_complete"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + + // Get the callback_id from the request + Map[] rq = TestUtils.getCurrentRQ(); + String callbackId = rq[0].get("callback_id"); + Assert.assertNotNull(callbackId); + Thread.sleep(1000); + Assert.assertEquals(2, contentRequestCount.get()); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + } catch (Exception ignored) { + } + }); + } + + /** + * Tests that journey trigger does NOT send content request when event request fails. + * The content refresh callback is only called on success, so failed event requests + * should not trigger content zone refresh. + */ + @Test + public void journeyTriggerEvents_noContentRequestOnEventFailure() throws Exception { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("journey_event"); + + final AtomicInteger contentRequestCount = new AtomicInteger(0); + final AtomicInteger eventRequestCount = new AtomicInteger(0); + + testJTEWithMockedWebServer((request, response) -> { + if (request.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + } + + // Track event requests + if (request.getPath().contains("events=")) { + eventRequestCount.incrementAndGet(); + response.setResponseCode(500) + .setBody("Internal Server Error"); + } + + // Server config request - return JTE config with refreshContentZone disabled + if (request.getPath().contains("&method=sc")) { + try { + response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .build()); + } catch (JSONException ignored) { + } + } + }, () -> { + try { + Thread.sleep(1000); + int initialContentCount = contentRequestCount.get(); + + // Record JTE event - this should try to send but fail + Countly.sharedInstance().events().recordEvent("journey_event"); + Thread.sleep(2000); + + // Event request should have been attempted + Assert.assertTrue(eventRequestCount.get() >= 1); + + // Content request should NOT have been made since event failed + // The callback only fires on success + Assert.assertEquals(initialContentCount, contentRequestCount.get()); + } catch (Exception ignored) { + } + }); + } + + /** + * Tests that journey trigger skips content refresh when already in content zone. + * When isCurrentlyInContentZone is true, refreshContentZone should skip. + */ + @Test + public void journeyTriggerEvents_skipsRefreshWhenInContentZone() throws Exception { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("journey_event"); + triggerEvents.add("journey_event_2"); + + AtomicInteger contentRequestCount = new AtomicInteger(0); + AtomicBoolean returnContent = new AtomicBoolean(false); + + testJTEWithMockedWebServer((request, response) -> { + if (request.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + // Return valid content JSON when returnContent is true + // This will set isCurrentlyInContentZone=true in ModuleContent + if (returnContent.get()) { + String contentJson = "{\"html\":\"https://countly.com\",\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100},\"l\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100}}}"; + response.setBody(contentJson); + } + } + + // Server config request - return JTE config with refreshContentZone disabled + if (request.getPath().contains("&method=sc")) { + try { + response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .contentZone(true) + .sessionTracking(false) // Disable to prevent auto sessions + .build()); + } catch (JSONException ignored) { + } + } + }, () -> { + try { + // Wait for server config to be fetched and applied + Thread.sleep(2000); + // verify that enter is called + Assert.assertEquals(1, contentRequestCount.get()); + + // Record JTE event - this adds request with callback to RQ + returnContent.set(true); + Countly.sharedInstance().events().recordEvent("journey_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + + Thread.sleep(2000); // Allow time for content to be fetched and TransparentActivity to launch + Assert.assertEquals(2, contentRequestCount.get()); + + // Note: RQ may contain session requests from activity lifecycle when TransparentActivity launches + // This is expected behavior - the core test is verifying content refresh is skipped + + // Record another JTE - should NOT trigger content refresh since isCurrentlyInContentZone=true + Countly.sharedInstance().events().recordEvent("journey_event_2"); + Thread.sleep(1000); + // Content request count should NOT increase since we're already in content zone, so refresh should skip + Assert.assertEquals(2, contentRequestCount.get()); + + // Finish all TransparentActivity instances before calling exitContentZone + // This ensures the activity lifecycle completes while SDK is still initialized + finishAllTransparentActivities(); + Thread.sleep(2000); // Wait for activity lifecycle to complete + + Countly.sharedInstance().contents().exitContentZone(); + } catch (InterruptedException ignored) { + } + }); + } + + /** + * Tests that multiple journey trigger events each trigger their own flush. + * Each JTE event should be immediately flushed with its own callback_id. + */ + @Test + public void journeyTriggerEvents_multipleEventsEachFlush() throws JSONException { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("jte_1"); + triggerEvents.add("jte_2"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .eventQueueSize(100) // High threshold to verify force flush + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + + // Record first JTE event + Countly.sharedInstance().events().recordEvent("jte_1"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + String firstCallbackId = TestUtils.getCurrentRQ()[0].get("callback_id"); + Assert.assertNotNull(firstCallbackId); + + // Record second JTE event + Countly.sharedInstance().events().recordEvent("jte_2"); + Assert.assertEquals(2, TestUtils.getCurrentRQ().length); + String secondCallbackId = TestUtils.getCurrentRQ()[1].get("callback_id"); + Assert.assertNotNull(secondCallbackId); + + // Each event should have its own callback_id + Assert.assertNotEquals(firstCallbackId, secondCallbackId); + } + + /** + * Tests that non-journey events stay queued while JTE events are flushed immediately. + * This verifies the selective flush behavior. + */ + @Test + public void journeyTriggerEvents_nonJteStaysQueued() throws JSONException { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("journey_event"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .eventQueueSize(100) // High threshold + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + // Record a non-JTE event - should stay in queue + Countly.sharedInstance().events().recordEvent("regular_event"); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + // Record another non-JTE event + Countly.sharedInstance().events().recordEvent("another_regular"); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + Assert.assertEquals(2, countlyStore.getEventQueueSize()); + + // Record a JTE event - should flush ALL events + Countly.sharedInstance().events().recordEvent("journey_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + // Verify the flushed request contains all 3 events + Map[] rq = TestUtils.getCurrentRQ(); + String events = rq[0].get("events"); + Assert.assertNotNull(events); + Assert.assertTrue(events.contains("regular_event")); + Assert.assertTrue(events.contains("another_regular")); + Assert.assertTrue(events.contains("journey_event")); + } + + /** + * Tests that JTE triggers content zone refresh with retry mechanism on empty responses. + * When the server returns an empty content response, the SDK should retry up to 3 times. + * Verifies: + * 1. JTE event flushes immediately with callback_id + * 2. Content fetch is triggered after successful event delivery + * 3. Empty responses trigger retry mechanism (total 4 requests: 1 initial + 3 retries) + */ + @Test + public void journeyTriggerEvents_refreshRetriesCorrectlyAfterProvidingEmptyResponse() throws Exception { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("journey_event"); + + final AtomicInteger contentRequestCount = new AtomicInteger(0); + final AtomicInteger eventRequestCount = new AtomicInteger(0); + + testJTEWithMockedWebServer((request, response) -> { + if (request.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + response.setBody("[]"); // Simulate empty content response + } + // Track event requests + if (request.getPath().contains("events=")) { + eventRequestCount.incrementAndGet(); + } + // Server config request - return JTE config with refreshContentZone disabled + if (request.getPath().contains("&method=sc")) { + try { + response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .contentZone(true) // Content zone enabled but refresh disabled + .build()); + } catch (JSONException ignored) { + } + } + }, () -> { + try { + Thread.sleep(1000); + + // Content zone is enabled, so enter should be called + Assert.assertEquals(1, contentRequestCount.get()); + + // Record JTE event - should still flush immediately with callback_id + Countly.sharedInstance().events().recordEvent("journey_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + + // Verify callback_id is present (callback is still registered) + Map[] rq = TestUtils.getCurrentRQ(); + Assert.assertTrue(rq[0].containsKey("callback_id")); + + Thread.sleep(4000); + + // Event should have been sent + Assert.assertTrue(eventRequestCount.get() >= 1); + + // Verify that retry happened 3 times + Assert.assertEquals(4, contentRequestCount.get()); + } catch (InterruptedException ignored) { + } + }); + } + + /** + * Tests that content zone refresh retries stop when valid content is received. + * When empty responses are followed by a valid content response, retries should cease. + * Verifies: + * 1. JTE event flushes immediately with callback_id + * 2. Empty responses trigger retry mechanism + * 3. Valid content response stops further retries (content shown, no more requests) + */ + @Test + public void journeyTriggerEvents_refreshRetryStopAfterValidContentResponse() throws Exception { + Set triggerEvents = new HashSet<>(); + triggerEvents.add("journey_event"); + + final AtomicInteger contentRequestCount = new AtomicInteger(0); + final AtomicInteger eventRequestCount = new AtomicInteger(0); + final AtomicBoolean returnContent = new AtomicBoolean(false); + + testJTEWithMockedWebServer((request, response) -> { + if (request.getPath().contains("&method=queue")) { + contentRequestCount.incrementAndGet(); + response.setBody("[]"); // Simulate empty content response + if (returnContent.get()) { + String contentJson = "{\"html\":\"https://countly.com\",\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100},\"l\":{\"x\":0,\"y\":0,\"w\":100,\"h\":100}}}"; + response.setBody(contentJson); + } + } + // Track event requests + if (request.getPath().contains("events=")) { + eventRequestCount.incrementAndGet(); + } + // Server config request - return JTE config with refreshContentZone disabled + if (request.getPath().contains("&method=sc")) { + try { + response.setBody(new ServerConfigBuilder().defaults() + .journeyTriggerEvents(triggerEvents) + .contentZone(true) // Content zone enabled but refresh disabled + .build()); + } catch (JSONException ignored) { + } + } + }, () -> { + try { + Thread.sleep(1000); + + // Content zone is enabled, so enter should be called + Assert.assertEquals(1, contentRequestCount.get()); + + // Record JTE event - should still flush immediately with callback_id + Countly.sharedInstance().events().recordEvent("journey_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + + // Verify callback_id is present (callback is still registered) + Map[] rq = TestUtils.getCurrentRQ(); + Assert.assertTrue(rq[0].containsKey("callback_id")); + + Thread.sleep(2000); + returnContent.set(true); + + // Event should have been sent + Assert.assertTrue(eventRequestCount.get() >= 1); + + // Verify that retry happened 2 times and it got the valid content on 3rd try + Assert.assertEquals(3, contentRequestCount.get()); + } catch (InterruptedException ignored) { + } + }); + } + + private void testJTEWithMockedWebServer(BiConsumer customRequestFlow, Runnable runnable) throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(new Dispatcher() { + @NotNull @Override public MockResponse dispatch(@NotNull RecordedRequest recordedRequest) throws InterruptedException { + MockResponse response = new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json").setBody("{\"result\": \"Success\"}"); + // Track content requests + customRequestFlow.accept(recordedRequest, response); + return response; + } + }); + + server.start(); + String serverUrl = server.url("/").toString(); + serverUrl = serverUrl.substring(0, serverUrl.length() - 1); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.metricProviderOverride = new MockedMetricProvider(); + countlyConfig.setServerURL(serverUrl); + Countly.sharedInstance().init(countlyConfig); + Countly.sharedInstance().moduleContent.CONTENT_START_DELAY_MS = 0; + Countly.sharedInstance().moduleContent.REFRESH_CONTENT_ZONE_DELAY_MS = 0; + + Thread.sleep(1000); + + runnable.run(); + + server.shutdown(); + } + } + + // ================ User Property Cache Limit Tests ================ + + /** + * Tests that user property cache limit default value is correct. + */ + @Test + public void userPropertyCacheLimit_defaultValue() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(100, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + } + + /** + * Tests that user property cache limit can be updated via server configuration. + */ + @Test + public void userPropertyCacheLimit_serverConfigUpdate() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(50).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(50, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + } + + /** + * Tests that user property cache limit can be configured via SDK behavior settings. + * This tests loading from local configuration rather than server response. + */ + @Test + public void userPropertyCacheLimit_configuredViaSdkBehaviorSettings() throws JSONException { + // Create a server config with custom value and use it as SDK behavior settings + String serverConfig = new ServerConfigBuilder().defaults().userPropertyCacheLimit(75).build(); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.setSDKBehaviorSettings(serverConfig); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(75, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + } + + /** + * Tests that user property cache limit enforcement removes oldest properties when exceeded. + * When more custom properties are set than the limit, oldest ones should be removed. + */ + @Test + public void userPropertyCacheLimit_enforcesLimitOnSetProperties() throws JSONException { + // Set a small cache limit + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(3).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(3, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + + // Set more properties than the limit + Map properties = new ConcurrentHashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + properties.put("prop3", "value3"); + properties.put("prop4", "value4"); + properties.put("prop5", "value5"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Only 3 properties should remain (the limit) + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.custom); + Assert.assertEquals(3, Countly.sharedInstance().moduleUserProfile.custom.size()); + } + + /** + * Tests that user property cache limit does not affect properties when under limit. + */ + @Test + public void userPropertyCacheLimit_allowsPropertiesUnderLimit() throws JSONException { + // Set a cache limit higher than properties we'll add + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(10).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Set fewer properties than the limit + Map properties = new ConcurrentHashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + properties.put("prop3", "value3"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // All properties should remain + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.custom); + Assert.assertEquals(3, Countly.sharedInstance().moduleUserProfile.custom.size()); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("prop1")); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("prop2")); + Assert.assertTrue(Countly.sharedInstance().moduleUserProfile.custom.containsKey("prop3")); + } + + /** + * Tests that named properties are not affected by user property cache limit. + * Named properties (name, email, etc.) should be separate from custom properties limit. + */ + @Test + public void userPropertyCacheLimit_namedPropertiesNotAffected() throws JSONException { + // Set a small cache limit for custom properties + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Set named properties plus custom properties exceeding limit + Map properties = new ConcurrentHashMap<>(); + properties.put("name", "John Doe"); + properties.put("email", "john@example.com"); + properties.put("username", "johndoe"); + properties.put("custom1", "value1"); + properties.put("custom2", "value2"); + properties.put("custom3", "value3"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Named properties should be set regardless of limit + Assert.assertEquals("John Doe", Countly.sharedInstance().moduleUserProfile.name); + Assert.assertEquals("john@example.com", Countly.sharedInstance().moduleUserProfile.email); + Assert.assertEquals("johndoe", Countly.sharedInstance().moduleUserProfile.username); + + // Custom properties should be limited to 2 + Assert.assertNotNull(Countly.sharedInstance().moduleUserProfile.custom); + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); + } + + /** + * Tests that cache limit enforcement works across multiple setProperties calls. + */ + @Test + public void userPropertyCacheLimit_enforcesAcrossMultipleCalls() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(3).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // First call - add 2 properties + Map props1 = new ConcurrentHashMap<>(); + props1.put("prop1", "value1"); + props1.put("prop2", "value2"); + Countly.sharedInstance().userProfile().setProperties(props1); + + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); + + // Second call - add 2 more (total 4, but limit is 3) + Map props2 = new ConcurrentHashMap<>(); + props2.put("prop3", "value3"); + props2.put("prop4", "value4"); + Countly.sharedInstance().userProfile().setProperties(props2); + + // Should be limited to 3 + Assert.assertEquals(3, Countly.sharedInstance().moduleUserProfile.custom.size()); + } + + /** + * Tests that cache limit of 1 only keeps one property. + */ + @Test + public void userPropertyCacheLimit_limitOfOne() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map properties = new ConcurrentHashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + properties.put("prop3", "value3"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Only 1 property should remain + Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.custom.size()); + } + + /** + * Tests that cache limit of 0 is treated as invalid and the default limit (100) is used. + * The SDK validation requires values > 0, so 0 is rejected. + */ + @Test + public void userPropertyCacheLimit_limitOfZero_usesDefault() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(0).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Map properties = new ConcurrentHashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + + Countly.sharedInstance().userProfile().setProperties(properties); + + // Limit of 0 is invalid (SDK requires > 0), so default (100) is used + // Both properties should be stored + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); + + // Verify default limit is applied + Assert.assertEquals(100, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + } + + /** + * Tests that cache limit is enforced on modification operations (incrementBy). + * When using incrementBy on multiple properties exceeding the limit, oldest should be removed. + */ + @Test + public void userPropertyCacheLimit_enforcesOnIncrementBy() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Add multiple increment operations exceeding the limit + Countly.sharedInstance().userProfile().incrementBy("counter1", 1); + Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + Countly.sharedInstance().userProfile().incrementBy("counter2", 2); + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + Countly.sharedInstance().userProfile().incrementBy("counter3", 3); + // Should be limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + Countly.sharedInstance().userProfile().incrementBy("counter4", 4); + // Still limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + /** + * Tests that cache limit is enforced on multiply operations. + */ + @Test + public void userPropertyCacheLimit_enforcesOnMultiply() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Countly.sharedInstance().userProfile().multiply("value1", 2); + Countly.sharedInstance().userProfile().multiply("value2", 3); + Countly.sharedInstance().userProfile().multiply("value3", 4); + + // Should be limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + /** + * Tests that cache limit is enforced on push operations. + */ + @Test + public void userPropertyCacheLimit_enforcesOnPush() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Countly.sharedInstance().userProfile().push("array1", "item1"); + Countly.sharedInstance().userProfile().push("array2", "item2"); + Countly.sharedInstance().userProfile().push("array3", "item3"); + + // Should be limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + /** + * Tests that cache limit is enforced on mixed modification operations. + */ + @Test + public void userPropertyCacheLimit_enforcesOnMixedMods() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(3).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Countly.sharedInstance().userProfile().incrementBy("counter", 1); + Countly.sharedInstance().userProfile().multiply("multiplier", 2); + Countly.sharedInstance().userProfile().push("array", "item"); + Countly.sharedInstance().userProfile().saveMax("maxValue", 100); + Countly.sharedInstance().userProfile().saveMin("minValue", 1); + + // Should be limited to 3 + Assert.assertEquals(3, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + /** + * Tests that updating the same property doesn't increase count. + */ + @Test + public void userPropertyCacheLimit_samePropertyUpdateDoesNotIncreaseCount() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Increment same property multiple times + Countly.sharedInstance().userProfile().incrementBy("counter", 1); + Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + Countly.sharedInstance().userProfile().incrementBy("counter", 2); + Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + Countly.sharedInstance().userProfile().incrementBy("counter", 3); + Assert.assertEquals(1, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + // Add a different property + Countly.sharedInstance().userProfile().incrementBy("otherCounter", 1); + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + /** + * Tests that custom properties and customMods have separate limits. + * Both should respect the same cache limit independently. + */ + @Test + public void userPropertyCacheLimit_separateLimitsForCustomAndMods() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().userPropertyCacheLimit(2).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Add properties via setProperties + Map properties = new ConcurrentHashMap<>(); + properties.put("prop1", "value1"); + properties.put("prop2", "value2"); + properties.put("prop3", "value3"); + Countly.sharedInstance().userProfile().setProperties(properties); + + // Custom should be limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); + + // Add modifications + Countly.sharedInstance().userProfile().incrementBy("counter1", 1); + Countly.sharedInstance().userProfile().incrementBy("counter2", 2); + Countly.sharedInstance().userProfile().incrementBy("counter3", 3); + + // CustomMods should also be limited to 2 + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + + // Both limits are independent + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.custom.size()); + Assert.assertEquals(2, Countly.sharedInstance().moduleUserProfile.customMods.size()); + } + + // ================ Filter Configuration Parsing Tests ================ + + /** + * Tests that filter configuration is correctly parsed from server response. + */ + @Test + public void filterConfigParsing_allFiltersCorrectlyParsed() throws JSONException { + Set eventBlacklist = new HashSet<>(); + eventBlacklist.add("blocked_event"); + + Set userPropertyBlacklist = new HashSet<>(); + userPropertyBlacklist.add("blocked_prop"); + + Set segmentationBlacklist = new HashSet<>(); + segmentationBlacklist.add("blocked_seg"); + + Map> eventSegBlacklist = new ConcurrentHashMap<>(); + Set eventSpecificSeg = new HashSet<>(); + eventSpecificSeg.add("specific_key"); + eventSegBlacklist.put("specific_event", eventSpecificSeg); + + Set journeyTriggers = new HashSet<>(); + journeyTriggers.add("journey_event"); + + ServerConfigBuilder builder = new ServerConfigBuilder().defaults() + .eventFilterList(eventBlacklist, false) + .userPropertyFilterList(userPropertyBlacklist, false) + .segmentationFilterList(segmentationBlacklist, false) + .eventSegmentationFilterMap(eventSegBlacklist, false) + .journeyTriggerEvents(journeyTriggers) + .userPropertyCacheLimit(200); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse(builder.build()); + Countly.sharedInstance().init(countlyConfig); + + // Verify all filters are correctly configured + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getEventFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().filterList.contains("blocked_event")); + + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getUserPropertyFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getUserPropertyFilterList().filterList.contains("blocked_prop")); + + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getSegmentationFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getSegmentationFilterList().filterList.contains("blocked_seg")); + + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getEventSegmentationFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventSegmentationFilterList().filterList.containsKey("specific_event")); + + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerEvents().contains("journey_event")); + + Assert.assertEquals(200, Countly.sharedInstance().moduleConfiguration.getUserPropertyCacheLimit()); + } + + /** + * Tests that blacklist mode is correctly set when using blacklist. + */ + @Test + public void filterConfigParsing_blacklistModeSet() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("blocked_event"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Should be blacklist mode + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getEventFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().filterList.contains("blocked_event")); + } + + /** + * Tests that whitelist mode is correctly set when using whitelist. + */ + @Test + public void filterConfigParsing_whitelistModeSet() throws JSONException { + Set whitelist = new HashSet<>(); + whitelist.add("allowed_event"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(whitelist, true).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Should be whitelist mode + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().isWhitelist); + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getEventFilterList().filterList.contains("allowed_event")); + } + + // ================ Edge Case Tests ================ + + /** + * Tests filter behavior with special characters in event names. + */ + @Test + public void edgeCase_specialCharactersInEventName() throws JSONException { + Set blacklist = new HashSet<>(); + blacklist.add("event with spaces"); + blacklist.add("event-with-dashes"); + blacklist.add("event_with_underscores"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // All should be blocked + Countly.sharedInstance().events().recordEvent("event with spaces"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + Countly.sharedInstance().events().recordEvent("event-with-dashes"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + Countly.sharedInstance().events().recordEvent("event_with_underscores"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + // This should pass + Countly.sharedInstance().events().recordEvent("normal_event"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + } + + /** + * Tests filter behavior with empty segmentation map. + */ + @Test + public void edgeCase_emptySegmentationMap() throws JSONException { + Set segBlacklist = new HashSet<>(); + segBlacklist.add("some_key"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().segmentationFilterList(segBlacklist, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Record event with empty segmentation - should work fine + Countly.sharedInstance().events().recordEvent("test_event", new HashMap<>()); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + } + + /** + * Tests filter behavior with null segmentation. + */ + @Test + public void edgeCase_nullSegmentation() throws JSONException { + Set segBlacklist = new HashSet<>(); + segBlacklist.add("some_key"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().segmentationFilterList(segBlacklist, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Record event with null segmentation - should work fine + Countly.sharedInstance().events().recordEvent("test_event"); + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + } + + /** + * Tests that filter configuration update works during runtime. + */ + @Test + public void edgeCase_filterConfigurationRuntimeUpdate() throws JSONException { + // Start with no filters + Set emptySet = new HashSet<>(); + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(emptySet, false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // Events should be allowed + Countly.sharedInstance().events().recordEvent("test_event"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + // Reinitialize with filter + Countly.sharedInstance().halt(); + countlyStore.clear(); // Clear storage for fresh start + + Set blacklist = new HashSet<>(); + blacklist.add("test_event"); + CountlyConfig config2 = TestUtils.createBaseConfig().enableManualSessionControl(); + config2.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventFilterList(blacklist, false).build() + ); + Countly.sharedInstance().init(config2); + + // Now event should be blocked + Countly.sharedInstance().events().recordEvent("test_event"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); // 0 because blocked + + // Different event should pass + Countly.sharedInstance().events().recordEvent("other_event"); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + } + + /** + * Tests multiple events with different filter rules applied correctly. + */ + @Test + public void edgeCase_multipleEventsWithDifferentFilters() throws JSONException { + Map> eventSegFilterMap = new ConcurrentHashMap<>(); + + Set event1Filter = new HashSet<>(); + event1Filter.add("key_a"); + eventSegFilterMap.put("event1", event1Filter); + + Set event2Filter = new HashSet<>(); + event2Filter.add("key_b"); + eventSegFilterMap.put("event2", event2Filter); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().eventSegmentationFilterMap(eventSegFilterMap, false).eventQueueSize(1).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // event1: key_a should be blocked, key_b allowed + Map seg1 = new ConcurrentHashMap<>(); + seg1.put("key_a", "value"); + seg1.put("key_b", "value"); + Countly.sharedInstance().events().recordEvent("event1", seg1); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateEventInRQ("event1", TestUtils.map("key_b", "value"), 0, 1, 0, 1); + + // event2: key_b should be blocked, key_a allowed + Map seg2 = new ConcurrentHashMap<>(); + seg2.put("key_a", "value"); + seg2.put("key_b", "value"); + Countly.sharedInstance().events().recordEvent("event2", seg2); + + Assert.assertEquals(2, TestUtils.getCurrentRQ().length); + validateEventInRQ("event2", TestUtils.map("key_a", "value"), 1, 2, 0, 1); + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java index cd9158f66..e35faea45 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java @@ -1937,7 +1937,7 @@ public void startView_consentRemoval() throws JSONException { try { validateView("test", 0.0, 3, 6, false, false, TestUtils.map(), "_CLY_", "_CLY_", null); validateView("test2", 0.0, 4, 6, false, false, TestUtils.map(), "_CLY_", "_CLY_", null); - } catch (Exception ignored) { + } catch (AssertionError ignored) { validateView("test", 0.0, 4, 6, false, false, TestUtils.map(), "_CLY_", "_CLY_", null); validateView("test2", 0.0, 3, 6, false, false, TestUtils.map(), "_CLY_", "_CLY_", null); } @@ -2062,6 +2062,92 @@ public void autoViewTracking_consentRemoval() throws JSONException { Assert.assertEquals(9, TestUtils.getCurrentRQ().length); } + /** + * "startView" with bg/fg switch case + * - Validate that after an auto stopped view is started and app gone to background + * running view should not stop because behavior is disabled + * - After coming from the background to foreground no view should be started because behavior is disabled + * + * @throws InterruptedException if the thread is interrupted + * @throws JSONException if the JSON is not valid + */ + @Test + public void startView_restartAfterActivityComesFromForeground_behaviorDisabled() throws InterruptedException, JSONException { + CountlyConfig countlyConfig = TestUtils.createScenarioEventIDConfig(TestUtils.incrementalViewIdGenerator(), TestUtils.incrementalEventIdGenerator()); + countlyConfig.setApplication(null); + countlyConfig.setContext(TestUtils.getContext()); + countlyConfig.disableViewRestartForManualRecording(); + countlyConfig.setEventQueueSizeToSend(1); + Countly countly = new Countly().init(countlyConfig); + + Activity activity = mock(Activity.class); + + TestUtils.assertRQSize(0); + + countly.onStart(activity); + countly.views().startView("test"); + + ModuleSessionsTests.validateSessionBeginRequest(0, TestUtils.commonDeviceId); + ModuleEventsTests.validateEventInRQ(TestUtils.commonDeviceId, ModuleViews.ORIENTATION_EVENT_KEY, null, 1, 0.0, 0.0, "ide1", "_CLY_", "", "_CLY_", 1, 3, 0, 1); + validateView("test", 0.0, 2, 3, true, true, null, "idv1", ""); + + Thread.sleep(1000); + + countly.onStop(); + ModuleSessionsTests.validateSessionEndRequest(3, 1, TestUtils.commonDeviceId); + Thread.sleep(1000); + countly.onStart(activity); + + ModuleSessionsTests.validateSessionBeginRequest(4, TestUtils.commonDeviceId); + ModuleEventsTests.validateEventInRQ(TestUtils.commonDeviceId, ModuleViews.ORIENTATION_EVENT_KEY, null, 1, 0.0, 0.0, "ide2", "_CLY_", "idv1", "_CLY_", 5, 6, 0, 1); + + Thread.sleep(1000); + + countly.views().stopViewWithName("test"); + validateView("test", 3.0, 6, 7, false, false, null, "idv1", ""); + countly.onStop(); + + ModuleSessionsTests.validateSessionEndRequest(7, 1, TestUtils.commonDeviceId); + TestUtils.assertRQSize(8); + } + + /** + * Auto view tracking with restart is disabled for manual views + * Validate that after an auto stopped view is started and app gone to background + * running view should stop because auto views are not affected by the disabled behavior + * + * @throws JSONException if the JSON is not valid + */ + @Test + public void autoViewTracking_restartDisabledForManualViews() throws JSONException, InterruptedException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig(TestUtils.getContext()); + countlyConfig.setLoggingEnabled(true); + countlyConfig.enableAutomaticViewTracking(); + countlyConfig.disableViewRestartForManualRecording(); + countlyConfig.setEventQueueSizeToSend(1); + + Countly countly = new Countly().init(countlyConfig); + + Activity activity = Mockito.mock(Activity.class); + countly.onStart(activity); + + ModuleSessionsTests.validateSessionBeginRequest(0, TestUtils.commonDeviceId); + ModuleEventsTests.validateEventInRQ("[CLY]_orientation", TestUtils.map("mode", "portrait"), 1, 0, 0, 1, 3); + validateView(activity.getClass().getName(), 0.0, 2, 3, true, true, TestUtils.map(), "_CLY_", "_CLY_", null); + Thread.sleep(1000); + countly.onStop(); + ModuleSessionsTests.validateSessionEndRequest(3, 1, TestUtils.commonDeviceId); + validateView(activity.getClass().getName(), 1.0, 4, 5, false, false, null, "_CLY_", "_CLY_"); + + countly.onStart(activity); + Thread.sleep(1000); + ModuleSessionsTests.validateSessionBeginRequest(5, TestUtils.commonDeviceId); + ModuleEventsTests.validateEventInRQ("[CLY]_orientation", TestUtils.map("mode", "portrait"), 1, 0, 0, 6, 8); + + validateView(activity.getClass().getName(), 0.0, 7, 8, true, true, TestUtils.map(), "_CLY_", "_CLY_", null); + Assert.assertEquals(8, TestUtils.getCurrentRQ().length); + } + static void validateView(String viewName, Double viewDuration, int idx, int size, boolean start, boolean visit, Map customSegmentation, String id, String pvid) throws JSONException { validateView(viewName, viewDuration, idx, size, start, visit, customSegmentation, id, pvid, null); } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java index 003c68726..607ede8f3 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java @@ -1,7 +1,11 @@ package ly.count.android.sdk; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.json.JSONException; import org.json.JSONObject; import org.junit.Assert; @@ -13,7 +17,12 @@ import static ly.count.android.sdk.ModuleConfiguration.keyRCustomEventTracking; import static ly.count.android.sdk.ModuleConfiguration.keyRDropOldRequestTime; import static ly.count.android.sdk.ModuleConfiguration.keyREnterContentZone; +import static ly.count.android.sdk.ModuleConfiguration.keyREventBlacklist; import static ly.count.android.sdk.ModuleConfiguration.keyREventQueueSize; +import static ly.count.android.sdk.ModuleConfiguration.keyREventSegmentationBlacklist; +import static ly.count.android.sdk.ModuleConfiguration.keyREventSegmentationWhitelist; +import static ly.count.android.sdk.ModuleConfiguration.keyREventWhitelist; +import static ly.count.android.sdk.ModuleConfiguration.keyRJourneyTriggerEvents; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitBreadcrumb; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitKeyLength; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitSegValues; @@ -25,11 +34,16 @@ import static ly.count.android.sdk.ModuleConfiguration.keyRNetworking; import static ly.count.android.sdk.ModuleConfiguration.keyRRefreshContentZone; import static ly.count.android.sdk.ModuleConfiguration.keyRReqQueueSize; +import static ly.count.android.sdk.ModuleConfiguration.keyRSegmentationBlacklist; +import static ly.count.android.sdk.ModuleConfiguration.keyRSegmentationWhitelist; import static ly.count.android.sdk.ModuleConfiguration.keyRServerConfigUpdateInterval; import static ly.count.android.sdk.ModuleConfiguration.keyRSessionTracking; import static ly.count.android.sdk.ModuleConfiguration.keyRSessionUpdateInterval; import static ly.count.android.sdk.ModuleConfiguration.keyRTimestamp; import static ly.count.android.sdk.ModuleConfiguration.keyRTracking; +import static ly.count.android.sdk.ModuleConfiguration.keyRUserPropertyBlacklist; +import static ly.count.android.sdk.ModuleConfiguration.keyRUserPropertyCacheLimit; +import static ly.count.android.sdk.ModuleConfiguration.keyRUserPropertyWhitelist; import static ly.count.android.sdk.ModuleConfiguration.keyRVersion; import static ly.count.android.sdk.ModuleConfiguration.keyRViewTracking; @@ -169,6 +183,64 @@ ServerConfigBuilder traceLinesLimit(int limit) { return this; } + ServerConfigBuilder userPropertyCacheLimit(int limit) { + config.put(keyRUserPropertyCacheLimit, limit); + return this; + } + + ServerConfigBuilder eventFilterList(Set filterList, boolean isWhitelist) { + // Remove the conflicting key to ensure mutual exclusivity + if (isWhitelist) { + config.remove(keyREventBlacklist); + config.put(keyREventWhitelist, filterList); + } else { + config.remove(keyREventWhitelist); + config.put(keyREventBlacklist, filterList); + } + return this; + } + + ServerConfigBuilder userPropertyFilterList(Set filterList, boolean isWhitelist) { + // Remove the conflicting key to ensure mutual exclusivity + if (isWhitelist) { + config.remove(keyRUserPropertyBlacklist); + config.put(keyRUserPropertyWhitelist, filterList); + } else { + config.remove(keyRUserPropertyWhitelist); + config.put(keyRUserPropertyBlacklist, filterList); + } + return this; + } + + ServerConfigBuilder segmentationFilterList(Set filterList, boolean isWhitelist) { + // Remove the conflicting key to ensure mutual exclusivity + if (isWhitelist) { + config.remove(keyRSegmentationBlacklist); + config.put(keyRSegmentationWhitelist, filterList); + } else { + config.remove(keyRSegmentationWhitelist); + config.put(keyRSegmentationBlacklist, filterList); + } + return this; + } + + ServerConfigBuilder eventSegmentationFilterMap(Map> filterMap, boolean isWhitelist) { + // Remove the conflicting key to ensure mutual exclusivity + if (isWhitelist) { + config.remove(keyREventSegmentationBlacklist); + config.put(keyREventSegmentationWhitelist, filterMap); + } else { + config.remove(keyREventSegmentationWhitelist); + config.put(keyREventSegmentationBlacklist, filterMap); + } + return this; + } + + ServerConfigBuilder journeyTriggerEvents(Set journeyTriggerEvents) { + config.put(keyRJourneyTriggerEvents, journeyTriggerEvents); + return this; + } + ServerConfigBuilder defaults() { // Feature flags tracking(true); @@ -198,6 +270,13 @@ ServerConfigBuilder defaults() { breadcrumbLimit(Countly.maxBreadcrumbCountDefault); traceLengthLimit(Countly.maxStackTraceLineLengthDefault); traceLinesLimit(Countly.maxStackTraceLinesPerThreadDefault); + userPropertyCacheLimit(100); + + eventFilterList(new HashSet<>(), false); + userPropertyFilterList(new HashSet<>(), false); + segmentationFilterList(new HashSet<>(), false); + eventSegmentationFilterMap(new ConcurrentHashMap<>(), false); + journeyTriggerEvents(new HashSet<>()); return this; } @@ -221,6 +300,7 @@ void validateAgainst(Countly countly) { validateFeatureFlags(countly); validateIntervalsAndSizes(countly); validateLimits(countly); + validateFilterSettings(countly); } private void validateFeatureFlags(Countly countly) { @@ -260,5 +340,35 @@ private void validateLimits(Countly countly) { Assert.assertEquals(config.get(keyRLimitBreadcrumb), countly.config_.sdkInternalLimits.maxBreadcrumbCount); Assert.assertEquals(config.get(keyRLimitTraceLength), countly.config_.sdkInternalLimits.maxStackTraceLineLength); Assert.assertEquals(config.get(keyRLimitTraceLine), countly.config_.sdkInternalLimits.maxStackTraceLinesPerThread); + Assert.assertEquals(config.get(keyRUserPropertyCacheLimit), countly.moduleConfiguration.getUserPropertyCacheLimit()); + } + + private void validateFilterSettings(Countly countly) { + Set eventFilterList = (Set) config.get(keyREventBlacklist); + if (eventFilterList == null) { + eventFilterList = (Set) config.get(keyREventWhitelist); + } + Assert.assertEquals(Objects.requireNonNull(eventFilterList).toString(), countly.moduleConfiguration.getEventFilterList().filterList.toString()); + + Set userPropertyFilterList = (Set) config.get(keyRUserPropertyBlacklist); + if (userPropertyFilterList == null) { + userPropertyFilterList = (Set) config.get(keyRUserPropertyWhitelist); + } + Assert.assertEquals(Objects.requireNonNull(userPropertyFilterList).toString(), countly.moduleConfiguration.getUserPropertyFilterList().filterList.toString()); + + Set segmentationFilterList = (Set) config.get(keyRSegmentationBlacklist); + if (segmentationFilterList == null) { + segmentationFilterList = (Set) config.get(keyRSegmentationWhitelist); + } + Assert.assertEquals(Objects.requireNonNull(segmentationFilterList).toString(), countly.moduleConfiguration.getSegmentationFilterList().filterList.toString()); + + Map> eventSegmentationFilterMap = (Map>) config.get(keyREventSegmentationBlacklist); + if (eventSegmentationFilterMap == null) { + eventSegmentationFilterMap = (Map>) config.get(keyREventSegmentationWhitelist); + } + Assert.assertEquals(Objects.requireNonNull(eventSegmentationFilterMap).toString(), countly.moduleConfiguration.getEventSegmentationFilterList().filterList.toString()); + + Set journeyTriggerEvents = (Set) config.get(keyRJourneyTriggerEvents); + Assert.assertEquals(Objects.requireNonNull(journeyTriggerEvents).toString(), countly.moduleConfiguration.getJourneyTriggerEvents().toString()); } } \ No newline at end of file diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java b/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java index b98094091..09e244675 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java @@ -44,7 +44,7 @@ public class TestUtils { public final static String commonAppKey = "appkey"; public final static String commonDeviceId = "1234"; public final static String SDK_NAME = "java-native-android"; - public final static String SDK_VERSION = "25.4.5"; + public final static String SDK_VERSION = "26.1.0"; public static final int MAX_THREAD_COUNT_PER_STACK_TRACE = 50; public static class Activity2 extends Activity { diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/scSE_SessionsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/scSE_SessionsTests.java index 604b9f1ef..ef9160976 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/scSE_SessionsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/scSE_SessionsTests.java @@ -66,7 +66,7 @@ public void tearDown() { */ @Test public void SE_200_CR_CG_M() throws InterruptedException { - CountlyConfig config = TestUtils.createBaseConfig().enableManualSessionControl().setRequiresConsent(true).setConsentEnabled(new String[] { "sessions" }); + CountlyConfig config = TestUtils.createBaseConfig().enableManualSessionControl().setRequiresConsent(true).setConsentEnabled(new String[] { "sessions" }).setTrackOrientationChanges(false); Countly countly = new Countly().init(config); flowManualSessions(countly); @@ -92,12 +92,11 @@ public void SE_200_CR_CG_M() throws InterruptedException { */ @Test public void SE_201_CNR_M() throws InterruptedException { - CountlyConfig config = TestUtils.createBaseConfig().enableManualSessionControl().setRequiresConsent(false); + CountlyConfig config = TestUtils.createBaseConfig().enableManualSessionControl().setRequiresConsent(false).setTrackOrientationChanges(false); Countly countly = new Countly().init(config); flowManualSessions(countly); - TestUtils.removeRequestContains("orientation"); //TODO fix for now, tweak this Assert.assertEquals(4, TestUtils.getCurrentRQ().length); validateSessionBeginRequest(0, TestUtils.commonDeviceId); validateSessionUpdateRequest(1, 2, TestUtils.commonDeviceId); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java index e6f36624e..e3e87068f 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java @@ -71,6 +71,7 @@ public void eventSaveScenario_manualSessions() throws JSONException { public void eventSaveScenario_onTimer() throws InterruptedException, JSONException { CountlyConfig config = TestUtils.createBaseConfig(); config.sessionUpdateTimerDelay = 2; // trigger update call for property save + config.setTrackOrientationChanges(false); // disable orientation tracking to avoid extra event Countly countly = new Countly().init(config); TestUtils.assertRQSize(0); // no begin session because of no consent @@ -120,6 +121,95 @@ public void eventSaveScenario_changeDeviceIDWithoutMerge() throws JSONException ModuleUserProfileTests.validateUserProfileRequest(2, 3, TestUtils.map(), TestUtils.map("theme", "light_mode")); } + /** + * Related user properties should not be saved with session calls, + * call order, user property before session, begin session, user property after begin session, update session, user property after update session, end session + * generated request order begin_session + update_session + user properties + end_session + * manual sessions are enabled + * UPDATE: session calls now trigger saving properties like events as well + * UPDATED_REQUEST_ORDER: user property before session + begin_session + user property after begin session + update_session + user property after update session + end_session + */ + @Test + public void eventSaveScenario_sessionCallsTriggersSave_M() throws JSONException, InterruptedException { + Countly countly = new Countly().init(TestUtils.createBaseConfig().enableManualSessionControl().setTrackOrientationChanges(false)); + + TestUtils.assertRQSize(0); + countly.userProfile().setProperty("before_session", true); + + countly.sessions().beginSession(); + TestUtils.assertRQSize(2); // only begin session request, UPDATE: user property request before session + ModuleUserProfileTests.validateUserProfileRequest(0, 2, TestUtils.map(), TestUtils.map("before_session", true)); + ModuleSessionsTests.validateSessionBeginRequest(1, TestUtils.commonDeviceId); + + countly.userProfile().setProperty("after_begin_session", true); + TestUtils.assertRQSize(2); // still begin session, UPDATE: user property request after begin session + // no new user property request because it is validated along with begin session + + Thread.sleep(2000); + + countly.sessions().updateSession(); + TestUtils.assertRQSize(4); // only begin session and update session requests, UPDATE: user property request after begin and update session + + ModuleUserProfileTests.validateUserProfileRequest(2, 4, TestUtils.map(), TestUtils.map("after_begin_session", true)); + ModuleSessionsTests.validateSessionUpdateRequest(3, 2, TestUtils.commonDeviceId); + + countly.userProfile().setProperty("after_update_session", true); + TestUtils.assertRQSize(4); // still begin session and update session requests + // no new user property request because it is validated along with update session + Thread.sleep(2000); + + countly.sessions().endSession(); + TestUtils.assertRQSize(6); // begin, update, user properties and end session requests + ModuleUserProfileTests.validateUserProfileRequest(4, 6, TestUtils.map(), TestUtils.map("after_update_session", true)); + ModuleSessionsTests.validateSessionEndRequest(5, 2, TestUtils.commonDeviceId); + } + + /** + * Related user properties should be saved with automatic session calls, + * call order, user property before session, user property after begin session, session, user property after update session + * generated request order user_properties + begin_session + user_properties + update_session + user properties + end_session + */ + @Test + public void eventSaveScenario_sessionCallsTriggersSave_A() throws JSONException, InterruptedException { + CountlyConfig config = TestUtils.createBaseConfig(TestUtils.getContext()).setTrackOrientationChanges(false); + TestLifecycleObserver testLifecycleObserver = new TestLifecycleObserver(); + config.lifecycleObserver = testLifecycleObserver; + config.setUpdateSessionTimerDelay(5); // Use 5 second timer for more reliable timing + Countly countly = new Countly().init(config); + + TestUtils.assertRQSize(0); + countly.userProfile().setProperty("before_session", true); + + testLifecycleObserver.bringToForeground(); + countly.onStart(null); + + TestUtils.assertRQSize(2); + ModuleUserProfileTests.validateUserProfileRequest(0, 2, TestUtils.map(), TestUtils.map("before_session", true)); + ModuleSessionsTests.validateSessionBeginRequest(1, TestUtils.commonDeviceId); + + countly.userProfile().setProperty("after_begin_session", true); + TestUtils.assertRQSize(2); + + Thread.sleep(6000); // Wait for session update timer (5s) to fire + + TestUtils.assertRQSize(4); + + ModuleUserProfileTests.validateUserProfileRequest(2, 4, TestUtils.map(), TestUtils.map("after_begin_session", true)); + ModuleSessionsTests.validateSessionUpdateRequest(3, 5, TestUtils.commonDeviceId); // duration 5 (timer delay) + + countly.userProfile().setProperty("after_update_session", true); + TestUtils.assertRQSize(4); + + Thread.sleep(2000); // 6+2=8s total, less than 10s (2 timer intervals) + + testLifecycleObserver.goToBackground(); + countly.onStop(); + + TestUtils.assertRQSize(6); + ModuleUserProfileTests.validateUserProfileRequest(4, 6, TestUtils.map(), TestUtils.map("after_update_session", true)); + ModuleSessionsTests.validateSessionEndRequest(5, 3, TestUtils.commonDeviceId); // duration ~3s since last update + } + /** * 1. 200_CNR_A * Init SDK @@ -433,7 +523,7 @@ public void UP_209_CR_CNG_M() { */ @Test public void UP_210_CNR_M_duration() throws InterruptedException, JSONException { - Countly countly = new Countly().init(TestUtils.createBaseConfig().enableManualSessionControl().setUpdateSessionTimerDelay(5)); + Countly countly = new Countly().init(TestUtils.createBaseConfig().enableManualSessionControl().setUpdateSessionTimerDelay(5).setTrackOrientationChanges(false)); sendUserData(countly); Thread.sleep(6000); diff --git a/sdk/src/androidTest/res/xml/network_security_config.xml b/sdk/src/androidTest/res/xml/network_security_config.xml new file mode 100644 index 000000000..7c5b45b40 --- /dev/null +++ b/sdk/src/androidTest/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + 0.0.0.0 + localhost + 127.0.0.1 + + \ No newline at end of file diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml index ef57e187b..1bc5f4831 100644 --- a/sdk/src/main/AndroidManifest.xml +++ b/sdk/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ android:theme="@android:style/Theme.Translucent.NoTitleBar" android:exported="false"/> diff --git a/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java b/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java index ec42ae165..75a13679c 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java @@ -1,5 +1,8 @@ package ly.count.android.sdk; +import java.util.Map; +import java.util.Set; + interface ConfigurationProvider { boolean getNetworkingEnabled(); @@ -31,4 +34,28 @@ interface ConfigurationProvider { int getBOMDuration(); int getRequestTimeoutDurationMillis(); + + int getUserPropertyCacheLimit(); + + // LISTING FILTERS + + FilterList> getEventFilterList(); + + FilterList> getUserPropertyFilterList(); + + FilterList> getSegmentationFilterList(); + + FilterList>> getEventSegmentationFilterList(); + + Set getJourneyTriggerEvents(); + + class FilterList { + T filterList; + boolean isWhitelist; + + FilterList(T filterList, boolean isWhitelist) { + this.filterList = filterList; + this.isWhitelist = isWhitelist; + } + } } diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java index 9d9a21d0b..0b6646345 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java @@ -51,7 +51,6 @@ of this software and associated documentation files (the "Software"), to deal public class ConnectionProcessor implements Runnable { private static final String CRLF = "\r\n"; private static final String charset = "UTF-8"; - private final StorageProvider storageProvider_; private final DeviceIdProvider deviceIdProvider_; final ConfigurationProvider configProvider_; @@ -64,7 +63,7 @@ public class ConnectionProcessor implements Runnable { private final Map requestHeaderCustomValues_; private final Runnable backoffCallback_; - + private final Map internalRequestCallbacks_; static String endPointOverrideTag = "&new_end_point="; ModuleLog L; @@ -78,7 +77,7 @@ private enum RequestResult { ConnectionProcessor(final String serverURL, final StorageProvider storageProvider, final DeviceIdProvider deviceIdProvider, final ConfigurationProvider configProvider, final RequestInfoProvider requestInfoProvider, final SSLContext sslContext, final Map requestHeaderCustomValues, ModuleLog logModule, - HealthTracker healthTracker, Runnable backoffCallback) { + HealthTracker healthTracker, Runnable backoffCallback, final Map internalRequestCallbacks) { serverURL_ = serverURL; storageProvider_ = storageProvider; deviceIdProvider_ = deviceIdProvider; @@ -87,6 +86,7 @@ private enum RequestResult { requestHeaderCustomValues_ = requestHeaderCustomValues; requestInfoProvider_ = requestInfoProvider; backoffCallback_ = backoffCallback; + internalRequestCallbacks_ = internalRequestCallbacks; L = logModule; this.healthTracker = healthTracker; } @@ -391,6 +391,10 @@ public void run() { if (storedRequests == null || storedRequestCount == 0) { L.i("[ConnectionProcessor] No requests in the queue, request queue skipped"); // currently no data to send, we are done for now + InternalRequestCallback globalCallback = internalRequestCallbacks_.get(ConnectionQueue.GLOBAL_RC_CALLBACK); + if (globalCallback != null) { + globalCallback.onRQFinished(); + } break; } @@ -460,6 +464,15 @@ public void run() { L.v("[ConnectionProcessor] Custom end point detected for the request:[" + customEndpoint + "]"); } + String[] callbackExtraction = Utils.extractValueFromString(requestData, "&callback_id=", "&"); + InternalRequestCallback requestCallback = null; + String callbackID = callbackExtraction[1]; + if (callbackID != null) { + requestData = callbackExtraction[0]; + requestCallback = internalRequestCallbacks_.get(callbackID); + L.v("[ConnectionProcessor] run, Internal request callback detected for the request"); + } + if (pcc != null) { pcc.TrackCounterTimeNs("ConnectionProcessorRun_04_NetworkCustomEndpoint", UtilsTime.getNanoTime() - pccTsStartEndpointCheck); } @@ -579,6 +592,10 @@ public void run() { // an 'if' needs to be used here so that a 'switch' statement does not 'eat' the 'break' call // that is used to get out of the request loop if (rRes == RequestResult.OK) { + if (requestCallback != null) { + requestCallback.onRequestCompleted(null, true); + internalRequestCallbacks_.remove(callbackID); + } // successfully submitted event data to Count.ly server, so remove // this one from the stored events collection storageProvider_.removeRequest(originalRequest); @@ -588,6 +605,10 @@ public void run() { break; } } else { + if (requestCallback != null) { + requestCallback.onRequestCompleted(responseString, false); + internalRequestCallbacks_.remove(callbackID); + } // will retry later // warning was logged above, stop processing, let next tick take care of retrying healthTracker.logFailedNetworkRequest(responseCode, responseString);//notify the health tracker of the issue @@ -601,6 +622,10 @@ public void run() { } } catch (Exception e) { L.d("[ConnectionProcessor] Got exception while trying to submit request data: [" + requestData + "] [" + e + "]"); + if (requestCallback != null) { + requestCallback.onRequestCompleted(e.getMessage(), false); + } + internalRequestCallbacks_.remove(callbackID); // if exception occurred, stop processing, let next tick take care of retrying if (pcc != null) { pcc.TrackCounterTimeNs("ConnectionProcessorRun_11_NetworkWholeQueueException", UtilsTime.getNanoTime() - pccTsStartWholeQueue); @@ -632,6 +657,13 @@ public void run() { L.i("[ConnectionProcessor] Device identified as an app crawler, removing request " + originalRequest); } + // Notify callback that request was dropped (not sent to server) + if (requestCallback != null) { + String reason = isRequestOld ? "Request too old" : "Device is app crawler"; + requestCallback.onRequestCompleted(reason, false); + internalRequestCallbacks_.remove(callbackID); + } + //remove stored data storageProvider_.removeRequest(originalRequest); } diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java index cfb6b315d..10266b4cf 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -25,7 +25,11 @@ of this software and associated documentation files (the "Software"), to deal import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -48,6 +52,7 @@ of this software and associated documentation files (the "Software"), to deal * of this bug in dexmaker: https://code.google.com/p/dexmaker/issues/detail?id=34 */ class ConnectionQueue implements RequestQueueProvider { + static final String GLOBAL_RC_CALLBACK = "global_request_callback"; private ExecutorService executor_; private Context context_; private Future connectionProcessorFuture_; @@ -70,6 +75,9 @@ class ConnectionQueue implements RequestQueueProvider { StorageProvider storageProvider; ConfigurationProvider configProvider; RequestInfoProvider requestInfoProvider; + private final Map internalRequestCallbacks = new ConcurrentHashMap<>(); + // Using CopyOnWriteArrayList for thread safety - allows iteration while modifications may occur from other threads + private final List internalGlobalRequestCallbackActions = new CopyOnWriteArrayList<>(); void setBaseInfoProvider(BaseInfoProvider bip) { baseInfoProvider = bip; @@ -91,6 +99,24 @@ void setContext(final Context context) { context_ = context; } + public ConnectionQueue() { + // Register the global callback that executes all registered actions when the request queue finishes processing + internalRequestCallbacks.put(GLOBAL_RC_CALLBACK, new InternalRequestCallback() { + @Override public void onRQFinished() { + // Execute each registered action with try-catch to prevent one failing action from blocking others + for (Runnable r : internalGlobalRequestCallbackActions) { + try { + r.run(); + } catch (Exception e) { + if (L != null) { + L.e("[ConnectionQueue] Exception while executing global request callback action: " + e.getMessage()); + } + } + } + } + }); + } + void setupSSLContext() { if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) { sslContext_ = null; @@ -208,7 +234,7 @@ public void beginSession(boolean locationDisabled, @Nullable String locationCoun Countly.sharedInstance().isBeginSessionSent = true; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -233,7 +259,7 @@ public void enrollToKeys(@NonNull String[] keys) { + "&keys=" + UtilsNetworking.encodedArrayBuilder(keys) + "&new_end_point=/o/sdk"; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -260,7 +286,7 @@ public void exitForKeys(@NonNull String[] keys) { data += "&keys=" + UtilsNetworking.encodedArrayBuilder(keys); } - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -286,7 +312,7 @@ public void updateSession(final int duration) { String data = prepareCommonRequestData(); data += "&session_duration=" + duration; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } } @@ -301,7 +327,7 @@ public void changeDeviceId(String deviceId, String oldDeviceId) { data += "&old_device_id=" + UtilsNetworking.urlEncodeString(oldDeviceId); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -330,7 +356,7 @@ public void tokenSession(String token, Countly.CountlyMessagingProvider provider @Override public void run() { L.d("[Connection Queue] Finished waiting 10 seconds adding token request"); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } }, 10, TimeUnit.SECONDS); @@ -356,7 +382,7 @@ public void endSession(final int duration) { data += "&session_duration=" + duration; } - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -373,7 +399,7 @@ public void sendLocation(boolean locationDisabled, String locationCountryCode, S data += prepareLocationData(locationDisabled, locationCountryCode, locationCity, locationGpsCoordinates, locationIpAddress); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -395,7 +421,7 @@ public void sendUserData(String userdata) { } String data = prepareCommonRequestData() + userdata; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -418,7 +444,7 @@ public void sendIndirectAttribution(@NonNull String attributionObj) { String param = "&aid=" + UtilsNetworking.urlEncodeString(attributionObj); String data = prepareCommonRequestData() + param; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -442,7 +468,7 @@ public void sendDirectAttributionTest(@NonNull String attributionData) { String res = "&attribution_data=" + UtilsNetworking.urlEncodeString(attributionData); String data = prepareCommonRequestData() + res; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -473,7 +499,7 @@ public void sendDirectAttributionLegacy(@NonNull String campaignID, @Nullable St } String data = prepareCommonRequestData() + res; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -498,7 +524,7 @@ public void sendCrashReport(@NonNull final String crashData, final boolean nonFa + "&crash=" + UtilsNetworking.urlEncodeString(crashData); //in case of a fatal crash, write it in sync to shared preferences - addRequestToQueue(data, !nonFatalCrash); + addRequestToQueue(data, !nonFatalCrash, null); tick(); } @@ -535,7 +561,7 @@ public void sendDirectRequest(@NonNull final Map requestData) { )); } - addRequestToQueue(data.toString(), false); + addRequestToQueue(data.toString(), false, null); tick(); } @@ -546,7 +572,7 @@ public void sendMetricsRequest(@NonNull String preparedMetrics) { } L.d("[ConnectionQueue] sendMetricsRequest"); - addRequestToQueue(prepareCommonRequestData() + "&metrics=" + preparedMetrics, false); + addRequestToQueue(prepareCommonRequestData() + "&metrics=" + preparedMetrics, false, null); tick(); } @@ -557,6 +583,17 @@ public void sendMetricsRequest(@NonNull String preparedMetrics) { * @throws IllegalStateException if context, app key, store, or server URL have not been set */ public void recordEvents(final String events) { + recordEvents(events, null); + } + + /** + * Records the specified events and sends them to the server. + * + * @param events URL-encoded JSON string of event data + * @param callback InternalRequestCallback to be called when request is finished + * @throws IllegalStateException if context, app key, store, or server URL have not been set + */ + public void recordEvents(final String events, InternalRequestCallback callback) { if (!checkInternalState()) { return; } @@ -569,7 +606,7 @@ public void recordEvents(final String events) { final String data = prepareCommonRequestData() + "&events=" + events; - addRequestToQueue(data, false); + addRequestToQueue(data, false, callback); tick(); } @@ -582,7 +619,7 @@ public void sendConsentChanges(String formattedConsentChanges) { final String data = prepareCommonRequestData() + "&consent=" + UtilsNetworking.urlEncodeString(formattedConsentChanges); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -609,7 +646,7 @@ public void sendAPMCustomTrace(String key, Long durationMs, Long startMs, Long e + "&count=1" + "&apm=" + UtilsNetworking.urlEncodeString(apmData); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -637,7 +674,7 @@ public void sendAPMNetworkTrace(String networkTraceKey, Long responseTimeMs, int + "&count=1" + "&apm=" + UtilsNetworking.urlEncodeString(apmData); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -662,7 +699,7 @@ public void sendAPMAppStart(long durationMs, Long startMs, Long endMs) { + "&count=1" + "&apm=" + UtilsNetworking.urlEncodeString(apmData); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -687,7 +724,7 @@ public void sendAPMScreenTime(boolean recordForegroundTime, long durationMs, Lon + "&count=1" + "&apm=" + UtilsNetworking.urlEncodeString(apmData); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -926,7 +963,7 @@ public void run() { } }, configProvider.getBOMDuration(), TimeUnit.SECONDS); } - }); + }, internalRequestCallbacks); cp.pcc = pcc; return cp; } @@ -944,8 +981,52 @@ public boolean queueContainsTemporaryIdItems() { return false; } - void addRequestToQueue(final @NonNull String requestData, final boolean writeInSync) { - storageProvider.addRequest(requestData, writeInSync); + /** + * Adds a request to the queue with an optional callback. + *

+ * When a callback is provided: + * - A unique UUID is generated and stored with the callback in internalRequestCallbacks + * - The callback_id is appended to the request data + * - When the request completes (success, failure, or dropped), the callback is invoked and removed + * + * @param requestData The request data to queue + * @param writeInSync Whether to write synchronously (used for crash reports) + * @param callback Optional callback to be notified when the request completes. May be null. + */ + void addRequestToQueue(final @NonNull String requestData, final boolean writeInSync, InternalRequestCallback callback) { + if (callback == null) { + storageProvider.addRequest(requestData, writeInSync); + } else { + String callbackID = UUID.randomUUID().toString(); + internalRequestCallbacks.put(callbackID, callback); + String callbackParam = "&callback_id=" + UtilsNetworking.urlEncodeString(callbackID); + storageProvider.addRequest(requestData + callbackParam, writeInSync); + } + } + + /** + * Registers an action to be executed when the request queue finishes processing (becomes empty). + *

+ * Important behaviors: + * - Actions persist across multiple queue completions (they are NOT automatically cleared) + * - Actions are executed in the order they were registered + * - If an action throws an exception, it is logged but does not prevent other actions from running + * - To clear all actions, call {@link #flushInternalGlobalRequestCallbackActions()} + * + * @param runnable The action to execute when the queue finishes + */ + void registerInternalGlobalRequestCallbackAction(Runnable runnable) { + internalGlobalRequestCallbackActions.add(runnable); + } + + /** + * Clears all registered global request callback actions. + *

+ * After calling this method, no actions will be executed on future queue completions + * until new actions are registered via {@link #registerInternalGlobalRequestCallbackAction(Runnable)}. + */ + void flushInternalGlobalRequestCallbackActions() { + internalGlobalRequestCallbackActions.clear(); } /** diff --git a/sdk/src/main/java/ly/count/android/sdk/Countly.java b/sdk/src/main/java/ly/count/android/sdk/Countly.java index b36a93b64..6c2c73be6 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -47,7 +47,7 @@ of this software and associated documentation files (the "Software"), to deal */ public class Countly { - private final String DEFAULT_COUNTLY_SDK_VERSION_STRING = "25.4.5"; + private final String DEFAULT_COUNTLY_SDK_VERSION_STRING = "26.1.0"; /** * Used as request meta data on every request */ @@ -960,6 +960,9 @@ public synchronized void halt() { moduleHealthCheck = null; moduleContent = null; + // Reset configuration values that may have been changed during runtime + EVENT_QUEUE_SIZE_THRESHOLD = 100; + COUNTLY_SDK_VERSION_STRING = DEFAULT_COUNTLY_SDK_VERSION_STRING; COUNTLY_SDK_NAME = DEFAULT_COUNTLY_SDK_NAME; @@ -987,11 +990,13 @@ void onStartInternal(Activity activity) { ++activityCount_; if (activityCount_ == 1) { // start the timer in the first activity - moduleConfiguration.fetchIfTimeIsUpForFetchingServerConfig(); + if (moduleConfiguration != null) { + moduleConfiguration.fetchIfTimeIsUpForFetchingServerConfig(); + } //if we open the first activity //and we are not using manual session control, //begin a session - if (!moduleSessions.manualSessionControlEnabled) { + if (moduleSessions != null && !moduleSessions.manualSessionControlEnabled) { moduleSessions.beginSessionInternal(); } } @@ -1014,7 +1019,7 @@ void onStopInternal() { } --activityCount_; - if (activityCount_ == 0 && !moduleSessions.manualSessionControlEnabled) { + if (activityCount_ == 0 && moduleSessions != null && !moduleSessions.manualSessionControlEnabled) { // if we don't use manual session control // Called when final Activity is stopped. // Sends an end session event to the server, also sends any unsent custom events. @@ -1165,7 +1170,8 @@ public void setLoggingEnabled(final boolean enableLogging) { * @param customHeaderValues map of header key/value pairs to add/override * @return Returns the same Countly instance for convenient chaining */ - /* package */ synchronized void addCustomNetworkRequestHeaders(Map customHeaderValues) { + /* package */ + synchronized void addCustomNetworkRequestHeaders(Map customHeaderValues) { if (!isInitialized()) { L.e("[addCustomNetworkRequestHeaders] SDK must be initialised before calling this method"); return; diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java index 3785cfd30..3b2131826 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java @@ -213,6 +213,12 @@ public class CountlyConfig { // If set to true, request queue cleaner will remove all overflow at once instead of gradually (loop limited) removing boolean disableGradualRequestCleaner = false; + // If set to true, the SDK will not store the default push consent state on initialization for not requiring consent + boolean disableStoringDefaultPushConsent = false; + + // If set to true, the SDK will not restart manual views while switching between foreground and background + boolean disableViewRestartForManualRecording = false; + /** * THIS VARIABLE SHOULD NOT BE USED * IT IS ONLY FOR INTERNAL TESTING @@ -1063,7 +1069,7 @@ public synchronized CountlyConfig setRequestTimeoutDuration(int requestTimeoutDu * Set the webview display option for Content and Feedback Widgets * * @param displayOption IMMERSIVE for full screen with hidden system UI, or - * SAFE_AREA to use app usable area and not overlap system UI + * SAFE_AREA to use app usable area and not overlap system UI * @return config content to chain calls */ public synchronized CountlyConfig setWebviewDisplayOption(WebViewDisplayOption displayOption) { @@ -1100,6 +1106,32 @@ public synchronized CountlyConfig disableGradualRequestCleaner() { return this; } + /** + * Disable storing the default push consent on initialization. + * By default, if consent is required and push consent is not set, + * the SDK was storing push consent as false on initialization. + * Now, if consent is not required, the SDK will store push consent as true on initialization. + * + * @return Returns the same config object for convenient linking + */ + public synchronized CountlyConfig disableStoringDefaultPushConsent() { + this.disableStoringDefaultPushConsent = true; + return this; + } + + /** + * Disable view restart when manual view recording is done. + * By default, if automatic view tracking is not enabled and a manual view is recorded, + * the SDK was restarting the views to properly track the view duration in bg/fg transitions. + * Now, with this option enabled, the SDK will not restart the views on manual view recording. + * + * @return Returns the same config object for convenient linking + */ + public synchronized CountlyConfig disableViewRestartForManualRecording() { + this.disableViewRestartForManualRecording = true; + return this; + } + /** * APM configuration interface to be used with CountlyConfig */ diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java index f759459e2..cd88a02a8 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -1,7 +1,10 @@ package ly.count.android.sdk; +import android.os.Build; import android.util.Log; +import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; import android.webkit.WebView; import android.webkit.WebViewClient; import java.net.URLDecoder; @@ -44,7 +47,6 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request @Override public void onPageFinished(WebView view, String url) { Log.v(Countly.TAG, "[CountlyWebViewClient] onPageFinished, url: [" + url + "]"); - super.onPageFinished(view, url); if (afterPageFinished != null) { pageLoadTime = System.currentTimeMillis() - pageLoadTime; boolean timeOut = (pageLoadTime / 1000L) >= 60; @@ -55,6 +57,31 @@ public void onPageFinished(WebView view, String url) { } } + @Override + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { + if (request.isForMainFrame() && afterPageFinished != null) { + String errorString; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + errorString = error.getDescription() + " (code: " + error.getErrorCode() + ")"; + } else { + errorString = error.toString(); + } + Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedError, error: [" + errorString + "]"); + + afterPageFinished.onPageLoaded(true); + afterPageFinished = null; + } + } + + @Override + public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { + if (request.isForMainFrame() && afterPageFinished != null) { + Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedHttpError, errorResponseCode: [" + errorResponse.getStatusCode() + "]"); + afterPageFinished.onPageLoaded(true); + afterPageFinished = null; + } + } + public void registerWebViewUrlListener(WebViewUrlListener listener) { this.listeners.add(listener); } diff --git a/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java b/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java new file mode 100644 index 000000000..baf22cc3b --- /dev/null +++ b/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java @@ -0,0 +1,50 @@ +package ly.count.android.sdk; + +/** + * Internal callback interface for tracking request completion and queue status. + *

+ * This interface provides two callback methods: + *

    + *
  • {@link #onRequestCompleted(String, boolean)} - Called when an individual request completes
  • + *
  • {@link #onRQFinished()} - Called when the entire request queue has finished processing
  • + *
+ *

+ * Both methods have default empty implementations, allowing implementations to override + * only the methods they need. + */ +interface InternalRequestCallback { + /** + * Called when a request completes processing. + *

+ * This callback is invoked in the following scenarios: + *

    + *
  • Success: response=null, success=true - Request was successfully sent to server
  • + *
  • Failure: response=errorMessage, success=false - Server returned an error or request failed
  • + *
  • Dropped: response=reason, success=false - Request was dropped (too old or from crawler)
  • + *
  • Exception: response=exceptionMessage, success=false - An exception occurred during processing
  • + *
+ *

+ * After this callback is invoked, the callback is automatically removed from the internal map + * and will not be called again. + * + * @param response The server response (null on success, error message on failure) + * @param success true if the request was successfully sent and acknowledged, false otherwise + */ + default void onRequestCompleted(String response, boolean success) { + } + + /** + * Called when the request queue finishes processing and becomes empty. + *

+ * This callback is invoked when: + *

    + *
  • The request queue is empty (no requests to process)
  • + *
  • The request queue returns null
  • + *
+ *

+ * This is typically used by the global callback registered in ConnectionQueue to execute + * all registered global request callback actions. + */ + default void onRQFinished() { + } +} diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java index df7abc8cc..0ff608396 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -2,7 +2,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.HashSet; import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -47,6 +52,17 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { final static String keyRBOMRQPercentage = "bom_rqp"; final static String keyRBOMRequestAge = "bom_ra"; final static String keyRBOMDuration = "bom_d"; + final static String keyRUserPropertyCacheLimit = "upcl"; + final static String keyREventBlacklist = "eb"; + final static String keyRUserPropertyBlacklist = "upb"; + final static String keyRSegmentationBlacklist = "sb"; + final static String keyREventSegmentationBlacklist = "esb"; // json + final static String keyREventWhitelist = "ew"; + final static String keyRUserPropertyWhitelist = "upw"; + final static String keyRSegmentationWhitelist = "sw"; + final static String keyREventSegmentationWhitelist = "esw"; // json + final static String keyRJourneyTriggerEvents = "jte"; + // FLAGS boolean currentVTracking = true; boolean currentVNetworking = true; @@ -64,6 +80,14 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { double currentVBOMRQPercentage = 0.5; int currentVBOMRequestAge = 24; // in hours int currentVBOMDuration = 60; // in seconds + int currentVUserPropertyCacheLimit = 100; + + // FILTERS + FilterList> currentVEventFilterList = new FilterList<>(new HashSet<>(), false); + FilterList> currentVUserPropertyFilterList = new FilterList<>(new HashSet<>(), false); + FilterList> currentVSegmentationFilterList = new FilterList<>(new HashSet<>(), false); + FilterList>> currentVEventSegmentationFilterList = new FilterList<>(new ConcurrentHashMap<>(), false); + Set currentVJourneyTriggerEvents = new HashSet<>(); // SERVER CONFIGURATION PARAMS Integer serverConfigUpdateInterval; // in hours @@ -207,6 +231,7 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { currentVBOMRQPercentage = extractValue(keyRBOMRQPercentage, sb, currentVBOMRQPercentage, currentVBOMRQPercentage, Double.class, (Double value) -> value > 0.0 && value < 1.0); currentVBOMRequestAge = extractValue(keyRBOMRequestAge, sb, currentVBOMRequestAge, currentVBOMRequestAge, Integer.class, (Integer value) -> value > 0); currentVBOMDuration = extractValue(keyRBOMDuration, sb, currentVBOMDuration, currentVBOMDuration, Integer.class, (Integer value) -> value > 0); + currentVUserPropertyCacheLimit = extractValue(keyRUserPropertyCacheLimit, sb, currentVUserPropertyCacheLimit, currentVUserPropertyCacheLimit, Integer.class, (Integer value) -> value > 0); clyConfig.setMaxRequestQueueSize(extractValue(keyRReqQueueSize, sb, clyConfig.maxRequestQueueSize, clyConfig.maxRequestQueueSize, Integer.class, (Integer value) -> value > 0)); clyConfig.setEventQueueSizeToSend(extractValue(keyREventQueueSize, sb, clyConfig.eventQueueSizeThreshold, Countly.sharedInstance().EVENT_QUEUE_SIZE_THRESHOLD, Integer.class, (Integer value) -> value > 0)); @@ -222,6 +247,8 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { clyConfig.setRequiresConsent(extractValue(keyRConsentRequired, sb, clyConfig.shouldRequireConsent, clyConfig.shouldRequireConsent)); clyConfig.setRequestDropAgeHours(extractValue(keyRDropOldRequestTime, sb, clyConfig.dropAgeHours, clyConfig.dropAgeHours, Integer.class, (Integer value) -> value >= 0)); + updateListingFilters(); + String updatedValues = sb.toString(); if (!updatedValues.isEmpty()) { L.i("[ModuleConfiguration] updateConfigVariables, SDK configuration has changed, notifying the SDK, new values: [" + updatedValues + "]"); @@ -229,6 +256,100 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { } } + private void updateListingFilters() { + L.d("[ModuleConfiguration] updateListingFilters, current listing filters before updating: \n" + + "Event Filter List: " + currentVEventFilterList.filterList + ", isWhitelist: " + currentVEventFilterList.isWhitelist + "\n" + + "User Property Filter List: " + currentVUserPropertyFilterList.filterList + ", isWhitelist: " + currentVUserPropertyFilterList.isWhitelist + "\n" + + "Segmentation Filter List: " + currentVSegmentationFilterList.filterList + ", isWhitelist: " + currentVSegmentationFilterList.isWhitelist + "\n" + + "Event Segmentation Filter List: " + currentVEventSegmentationFilterList.filterList + ", isWhitelist: " + currentVEventSegmentationFilterList.isWhitelist + "\n" + + "Journey Trigger Events: " + currentVJourneyTriggerEvents); + JSONArray eventBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyREventBlacklist); + JSONArray eventWhitelistJSARR = latestRetrievedConfiguration.optJSONArray(keyREventWhitelist); + JSONArray userPropertyBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyRUserPropertyBlacklist); + JSONArray userPropertyWhitelistJSARR = latestRetrievedConfiguration.optJSONArray(keyRUserPropertyWhitelist); + JSONArray segmentationBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyRSegmentationBlacklist); + JSONArray segmentationWhitelistJSARR = latestRetrievedConfiguration.optJSONArray(keyRSegmentationWhitelist); + JSONObject eventSegmentationBlacklistJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationBlacklist); + JSONObject eventSegmentationWhitelistJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationWhitelist); + JSONArray journeyTriggerEventsJSARR = latestRetrievedConfiguration.optJSONArray(keyRJourneyTriggerEvents); + + if (eventBlacklistJSARR != null) { + extractFilterSetFromJSONArray(eventBlacklistJSARR, currentVEventFilterList.filterList); + currentVEventFilterList.isWhitelist = false; + } else if (eventWhitelistJSARR != null) { + extractFilterSetFromJSONArray(eventWhitelistJSARR, currentVEventFilterList.filterList); + currentVEventFilterList.isWhitelist = true; + } + + if (userPropertyBlacklistJSARR != null) { + extractFilterSetFromJSONArray(userPropertyBlacklistJSARR, currentVUserPropertyFilterList.filterList); + currentVUserPropertyFilterList.isWhitelist = false; + } else if (userPropertyWhitelistJSARR != null) { + extractFilterSetFromJSONArray(userPropertyWhitelistJSARR, currentVUserPropertyFilterList.filterList); + currentVUserPropertyFilterList.isWhitelist = true; + } + + if (segmentationBlacklistJSARR != null) { + extractFilterSetFromJSONArray(segmentationBlacklistJSARR, currentVSegmentationFilterList.filterList); + currentVSegmentationFilterList.isWhitelist = false; + } else if (segmentationWhitelistJSARR != null) { + extractFilterSetFromJSONArray(segmentationWhitelistJSARR, currentVSegmentationFilterList.filterList); + currentVSegmentationFilterList.isWhitelist = true; + } + + if (eventSegmentationBlacklistJSOBJ != null) { + currentVEventSegmentationFilterList.filterList.clear(); + currentVEventSegmentationFilterList.isWhitelist = false; + Iterator keys = eventSegmentationBlacklistJSOBJ.keys(); + while (keys.hasNext()) { + String key = keys.next(); + JSONArray jsonArray = eventSegmentationBlacklistJSOBJ.optJSONArray(key); + if (jsonArray != null) { + Set filterSet = new HashSet<>(); + extractFilterSetFromJSONArray(jsonArray, filterSet); + currentVEventSegmentationFilterList.filterList.put(key, filterSet); + } + } + } else if (eventSegmentationWhitelistJSOBJ != null) { + currentVEventSegmentationFilterList.filterList.clear(); + currentVEventSegmentationFilterList.isWhitelist = true; + Iterator keys = eventSegmentationWhitelistJSOBJ.keys(); + while (keys.hasNext()) { + String key = keys.next(); + JSONArray jsonArray = eventSegmentationWhitelistJSOBJ.optJSONArray(key); + if (jsonArray != null) { + Set filterSet = new HashSet<>(); + extractFilterSetFromJSONArray(jsonArray, filterSet); + currentVEventSegmentationFilterList.filterList.put(key, filterSet); + } + } + } + + if (journeyTriggerEventsJSARR != null) { + extractFilterSetFromJSONArray(journeyTriggerEventsJSARR, currentVJourneyTriggerEvents); + } + + L.d("[ModuleConfiguration] updateListingFilters, current listing filters after updating: \n" + + "Event Filter List: " + currentVEventFilterList.filterList + ", isWhitelist: " + currentVEventFilterList.isWhitelist + "\n" + + "User Property Filter List: " + currentVUserPropertyFilterList.filterList + ", isWhitelist: " + currentVUserPropertyFilterList.isWhitelist + "\n" + + "Segmentation Filter List: " + currentVSegmentationFilterList.filterList + ", isWhitelist: " + currentVSegmentationFilterList.isWhitelist + "\n" + + "Event Segmentation Filter List: " + currentVEventSegmentationFilterList.filterList + ", isWhitelist: " + currentVEventSegmentationFilterList.isWhitelist + "\n" + + "Journey Trigger Events: " + currentVJourneyTriggerEvents); + } + + private void extractFilterSetFromJSONArray(@Nullable JSONArray jsonArray, @NonNull Set targetSet) { + if (jsonArray == null) { + return; + } + targetSet.clear(); + for (int i = 0; i < jsonArray.length(); i++) { + String item = jsonArray.optString(i, null); + if (item != null) { + targetSet.add(item); + } + } + } + boolean validateServerConfig(@NonNull JSONObject config) { JSONObject newInner = config.optJSONObject(keyRConfig); @@ -293,6 +414,7 @@ private void removeUnsupportedKeys(@NonNull JSONObject newInner) { case keyRLimitBreadcrumb: case keyRLimitTraceLine: case keyRLimitTraceLength: + case keyRUserPropertyCacheLimit: isValid = value instanceof Integer && ((Integer) value) > 0; break; @@ -310,6 +432,21 @@ private void removeUnsupportedKeys(@NonNull JSONObject newInner) { case keyRBOMRQPercentage: isValid = value instanceof Double && ((Double) value > 0.0 && (Double) value < 1.0); break; + + // --- Filtering keys --- + case keyREventBlacklist: + case keyRSegmentationBlacklist: + case keyRUserPropertyBlacklist: + case keyREventWhitelist: + case keyRSegmentationWhitelist: + case keyRUserPropertyWhitelist: + case keyRJourneyTriggerEvents: + isValid = value instanceof JSONArray; + break; + case keyREventSegmentationBlacklist: + case keyREventSegmentationWhitelist: + isValid = value instanceof JSONObject; + break; // --- Unknown keys --- default: L.w("[ModuleConfiguration] removeUnsupportedKeys, Unknown key: [" + key + "], removing it. value: [" + value + "]"); @@ -353,6 +490,8 @@ void saveAndStoreDownloadedConfig(@NonNull JSONObject config) { L.w("[ModuleConfiguration] saveAndStoreDownloadedConfig, Failed to merge version/timestamp.", e); } + removeListingFilterKeysFromConfig(newInner); + Iterator keys = newInner.keys(); while (keys.hasNext()) { String key = keys.next(); @@ -370,6 +509,34 @@ void saveAndStoreDownloadedConfig(@NonNull JSONObject config) { storageProvider.setServerConfig(latestRetrievedConfigurationFull.toString()); } + private void removeListingFilterKeysFromConfig(JSONObject newConfig) { + boolean hasAnyWhitelist = newConfig.has(keyREventWhitelist) + || newConfig.has(keyRUserPropertyWhitelist) + || newConfig.has(keyRSegmentationWhitelist) + || newConfig.has(keyREventSegmentationWhitelist); + + boolean hasAnyBlacklist = newConfig.has(keyREventBlacklist) + || newConfig.has(keyRUserPropertyBlacklist) + || newConfig.has(keyRSegmentationBlacklist) + || newConfig.has(keyREventSegmentationBlacklist); + + // Only remove opposite type when we actually have data for current type + if (hasAnyWhitelist) { + latestRetrievedConfiguration.remove(keyREventBlacklist); + latestRetrievedConfiguration.remove(keyRUserPropertyBlacklist); + latestRetrievedConfiguration.remove(keyRSegmentationBlacklist); + latestRetrievedConfiguration.remove(keyREventSegmentationBlacklist); + } + + if (hasAnyBlacklist) { + latestRetrievedConfiguration.remove(keyREventWhitelist); + latestRetrievedConfiguration.remove(keyRUserPropertyWhitelist); + latestRetrievedConfiguration.remove(keyRSegmentationWhitelist); + latestRetrievedConfiguration.remove(keyREventSegmentationWhitelist); + } + // If neither has data, don't remove anything - preserve existing filters + } + /** * Perform network request for retrieving latest config * If valid config is downloaded, save it, and update the values @@ -498,4 +665,28 @@ public boolean getTrackingEnabled() { @Override public int getRequestTimeoutDurationMillis() { return _cly.config_.requestTimeoutDuration * 1000; } + + @Override public int getUserPropertyCacheLimit() { + return currentVUserPropertyCacheLimit; + } + + @Override public FilterList> getEventFilterList() { + return currentVEventFilterList; + } + + @Override public FilterList> getUserPropertyFilterList() { + return currentVUserPropertyFilterList; + } + + @Override public FilterList> getSegmentationFilterList() { + return currentVSegmentationFilterList; + } + + @Override public FilterList>> getEventSegmentationFilterList() { + return currentVEventSegmentationFilterList; + } + + @Override public Set getJourneyTriggerEvents() { + return currentVJourneyTriggerEvents; + } } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConsent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConsent.java index a4477d57e..2671bfd82 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConsent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConsent.java @@ -284,6 +284,10 @@ void initFinished(@NonNull final CountlyConfig config) { if (L.logEnabled()) { checkAllConsentInternal(); } + } else if (!config.disableStoringDefaultPushConsent) { + //if consent is not required, we need to make sure that the push consent is set to true + //so that the "getConsentPushNoInit" returns true + doPushConsentSpecialAction(true); } } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index ade4eb2eb..7e6b9aab4 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -4,6 +4,8 @@ import android.content.Intent; import android.content.res.Configuration; import android.content.res.Resources; +import android.os.Handler; +import android.os.Looper; import android.util.DisplayMetrics; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -21,6 +23,7 @@ public class ModuleContent extends ModuleBase { CountlyTimer countlyTimer; private boolean shouldFetchContents = false; private boolean isCurrentlyInContentZone = false; + private boolean isCurrentlyRetrying = false; private int zoneTimerInterval; private final ContentCallback globalContentCallback; private int waitForDelay = 0; @@ -48,14 +51,14 @@ void onSdkConfigurationChanged(@NonNull CountlyConfig config) { exitContentZoneInternal(); } waitForDelay = 0; - enterContentZoneInternal(null, 0); + enterContentZoneInternal(null, 0, null); } } @Override void initFinished(@NotNull CountlyConfig config) { if (configProvider.getContentZoneEnabled()) { - enterContentZoneInternal(null, 0); + enterContentZoneInternal(null, 0, null); } } @@ -64,9 +67,20 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { if (UtilsDevice.cutout == null && activity != null) { UtilsDevice.getCutout(activity); } + if (isCurrentlyInContentZone + && activity != null + && !(activity instanceof TransparentActivity)) { + try { + Intent bringToFront = new Intent(activity, TransparentActivity.class); + bringToFront.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + activity.startActivity(bringToFront); + } catch (Exception ex) { + L.w("[ModuleContent] onActivityStarted, failed to reorder TransparentActivity to front", ex); + } + } } - void fetchContentsInternal(@NonNull String[] categories) { + void fetchContentsInternal(@NonNull String[] categories, @Nullable Runnable callbackOnFailure) { L.d("[ModuleContent] fetchContentsInternal, shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(categories) + "]"); DisplayMetrics displayMetrics = deviceInfo.mp.getDisplayMetrics(_cly.context_); @@ -85,46 +99,45 @@ void fetchContentsInternal(@NonNull String[] categories) { if (validateResponse(checkResponse)) { L.d("[ModuleContent] fetchContentsInternal, got new content data, showing it"); - iRGenerator.CreatePreflightRequestMaker().doWork(checkResponse.optString("html"), null, cp, false, true, preflightResponse -> { - if (preflightResponse == null) { - L.d("[ModuleContent] fetchContentsInternal, preflight check failed, skipping showing content"); - return; - } - - Map placementCoordinates = parseContent(checkResponse, displayMetrics); - if (placementCoordinates.isEmpty()) { - L.d("[ModuleContent] fetchContentsInternal, placement coordinates are empty, skipping"); - return; - } - - L.d("[ModuleContent] fetchContentsInternal, preflight check succeeded"); - Intent intent = new Intent(_cly.context_, TransparentActivity.class); - intent.putExtra(TransparentActivity.CONFIGURATION_LANDSCAPE, placementCoordinates.get(Configuration.ORIENTATION_LANDSCAPE)); - intent.putExtra(TransparentActivity.CONFIGURATION_PORTRAIT, placementCoordinates.get(Configuration.ORIENTATION_PORTRAIT)); - intent.putExtra(TransparentActivity.ORIENTATION, _cly.context_.getResources().getConfiguration().orientation); - - Long id = System.currentTimeMillis(); - intent.putExtra(TransparentActivity.ID_CALLBACK, id); - if (globalContentCallback != null) { - TransparentActivity.contentCallbacks.put(id, globalContentCallback); - } - - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - _cly.context_.startActivity(intent); - - shouldFetchContents = false; // disable fetching contents until the next time, this will disable the timer fetching - isCurrentlyInContentZone = true; - }, L); + Map placementCoordinates = parseContent(checkResponse, displayMetrics); + if (placementCoordinates.isEmpty()) { + L.d("[ModuleContent] fetchContentsInternal, placement coordinates are empty, skipping"); + return; + } + + Intent intent = new Intent(_cly.context_, TransparentActivity.class); + intent.putExtra(TransparentActivity.CONFIGURATION_LANDSCAPE, placementCoordinates.get(Configuration.ORIENTATION_LANDSCAPE)); + intent.putExtra(TransparentActivity.CONFIGURATION_PORTRAIT, placementCoordinates.get(Configuration.ORIENTATION_PORTRAIT)); + intent.putExtra(TransparentActivity.ORIENTATION, _cly.context_.getResources().getConfiguration().orientation); + + Long id = System.currentTimeMillis(); + intent.putExtra(TransparentActivity.ID_CALLBACK, id); + if (globalContentCallback != null) { + TransparentActivity.contentCallbacks.put(id, globalContentCallback); + } + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + _cly.context_.startActivity(intent); + + shouldFetchContents = false; // disable fetching contents until the next time, this will disable the timer fetching + isCurrentlyInContentZone = true; + isCurrentlyRetrying = false; } else { L.w("[ModuleContent] fetchContentsInternal, response is not valid, skipping"); + if (callbackOnFailure != null) { + callbackOnFailure.run(); + } } } catch (Exception ex) { L.e("[ModuleContent] fetchContentsInternal, Encountered internal issue while trying to fetch contents, [" + ex + "]"); + if (callbackOnFailure != null) { + callbackOnFailure.run(); + } } }, L); } - private void enterContentZoneInternal(@Nullable String[] categories, final int initialDelayMS) { + private void enterContentZoneInternal(@Nullable String[] categories, final int initialDelayMS, @Nullable Runnable callbackOnFailure) { if (!consentProvider.getConsent(Countly.CountlyFeatureNames.content)) { L.w("[ModuleContent] enterContentZoneInternal, Consent is not granted, skipping"); return; @@ -159,22 +172,71 @@ private void enterContentZoneInternal(@Nullable String[] categories, final int i contentInitialDelay += CONTENT_START_DELAY_MS; } - countlyTimer.startTimer(zoneTimerInterval, contentInitialDelay, new Runnable() { - @Override public void run() { - L.d("[ModuleContent] enterContentZoneInternal, waitForDelay: [" + waitForDelay + "], shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(validCategories) + "]"); - if (waitForDelay > 0) { - waitForDelay--; - return; + if (countlyTimer != null) { // for tests, in normal conditions this should never be null here + countlyTimer.startTimer(zoneTimerInterval, contentInitialDelay, new Runnable() { + @Override public void run() { + L.d("[ModuleContent] enterContentZoneInternal, waitForDelay: [" + waitForDelay + "], shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(validCategories) + "]"); + if (waitForDelay > 0) { + waitForDelay--; + return; + } + + if (!shouldFetchContents) { + L.w("[ModuleContent] enterContentZoneInternal, shouldFetchContents is false, skipping"); + return; + } + + fetchContentsInternal(validCategories, callbackOnFailure); } + }, L); + } + } - if (!shouldFetchContents) { - L.w("[ModuleContent] enterContentZoneInternal, shouldFetchContents is false, skipping"); + private void enterContentZoneWithRetriesInternal() { + if (isCurrentlyRetrying) { + L.w("[ModuleContent] enterContentZoneWithRetriesInternal, already retrying, skipping"); + return; + } + isCurrentlyRetrying = true; + Handler handler = new Handler(Looper.getMainLooper()); + int maxRetries = 3; + int delayMillis = 1000; + + Runnable retryRunnable = new Runnable() { + int attempt = 0; + + @Override + public void run() { + if (isCurrentlyInContentZone) { + isCurrentlyRetrying = false; // Reset flag on success return; } - fetchContentsInternal(validCategories); + if (countlyTimer != null) { // for tests + countlyTimer.stopTimer(L); + } + + final Runnable self = this; // Capture reference to outer Runnable + + enterContentZoneInternal(null, 0, new Runnable() { + @Override public void run() { + if (isCurrentlyInContentZone) { + isCurrentlyRetrying = false; // Reset flag on success + return; + } + attempt++; + if (attempt < maxRetries) { + handler.postDelayed(self, delayMillis); + } else { + L.w("[ModuleContent] enterContentZoneWithRetriesInternal, " + maxRetries + " attempted"); + isCurrentlyRetrying = false; + } + } + }); } - }, L); + }; + + handler.post(retryRunnable); } void notifyAfterContentIsClosed() { @@ -194,8 +256,8 @@ private String prepareContentFetchRequest(@NonNull DisplayMetrics displayMetrics int totalWidthPx = displayMetrics.widthPixels; int totalHeightPx = displayMetrics.heightPixels; - int totalWidthDp = (int) Math.ceil(totalWidthPx / displayMetrics.density); - int totalHeightDp = (int) Math.ceil(totalHeightPx / displayMetrics.density); + int totalWidthDp = (int) Math.floor(totalWidthPx / displayMetrics.density); + int totalHeightDp = (int) Math.floor(totalHeightPx / displayMetrics.density); L.d("[ModuleContent] prepareContentFetchRequest, total screen dimensions (px): [" + totalWidthPx + "x" + totalHeightPx + "], (dp): [" + totalWidthDp + "x" + totalHeightDp + "], density: [" + displayMetrics.density + "]"); WebViewDisplayOption displayOption = _cly.config_.webViewDisplayOption; @@ -204,13 +266,13 @@ private String prepareContentFetchRequest(@NonNull DisplayMetrics displayMetrics if (displayOption == WebViewDisplayOption.SAFE_AREA) { L.d("[ModuleContent] prepareContentFetchRequest, calculating safe area dimensions..."); SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(_cly.context_, L); - + // px to dp - portraitWidth = (int) Math.ceil(safeArea.portraitWidth / displayMetrics.density); - portraitHeight = (int) Math.ceil(safeArea.portraitHeight / displayMetrics.density); - landscapeWidth = (int) Math.ceil(safeArea.landscapeWidth / displayMetrics.density); - landscapeHeight = (int) Math.ceil(safeArea.landscapeHeight / displayMetrics.density); - + portraitWidth = (int) Math.floor(safeArea.portraitWidth / displayMetrics.density); + portraitHeight = (int) Math.floor(safeArea.portraitHeight / displayMetrics.density); + landscapeWidth = (int) Math.floor(safeArea.landscapeWidth / displayMetrics.density); + landscapeHeight = (int) Math.floor(safeArea.landscapeHeight / displayMetrics.density); + L.d("[ModuleContent] prepareContentFetchRequest, safe area dimensions (px->dp) - Portrait: [" + safeArea.portraitWidth + "x" + safeArea.portraitHeight + " px] -> [" + portraitWidth + "x" + portraitHeight + " dp], topOffset: [" + safeArea.portraitTopOffset + " px]"); L.d("[ModuleContent] prepareContentFetchRequest, safe area dimensions (px->dp) - Landscape: [" + safeArea.landscapeWidth + "x" + safeArea.landscapeHeight + " px] -> [" + landscapeWidth + "x" + landscapeHeight + " dp], topOffset: [" + safeArea.landscapeTopOffset + " px]"); } else { @@ -221,7 +283,7 @@ private String prepareContentFetchRequest(@NonNull DisplayMetrics displayMetrics portraitHeight = portrait ? scaledHeight : scaledWidth; landscapeWidth = portrait ? scaledHeight : scaledWidth; landscapeHeight = portrait ? scaledWidth : scaledHeight; - + L.d("[ModuleContent] prepareContentFetchRequest, using immersive mode (full screen) dimensions (dp) - Portrait: [" + portraitWidth + "x" + portraitHeight + "], Landscape: [" + landscapeWidth + "x" + landscapeHeight + "]"); } @@ -250,24 +312,24 @@ Map parseContent(@NonNull JSONObject respons WebViewDisplayOption displayOption = _cly.config_.webViewDisplayOption; SafeAreaDimensions safeArea = null; - + if (displayOption == WebViewDisplayOption.SAFE_AREA) { L.d("[ModuleContent] parseContent, calculating safe area for coordinate adjustment..."); safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(_cly.context_, L); } - placementCoordinates.put(Configuration.ORIENTATION_PORTRAIT, - extractOrientationPlacements(coordinates, displayMetrics.density, "p", content, + placementCoordinates.put(Configuration.ORIENTATION_PORTRAIT, + extractOrientationPlacements(coordinates, displayMetrics.density, "p", content, displayOption, safeArea != null ? safeArea.portraitTopOffset : 0, safeArea != null ? safeArea.portraitLeftOffset : 0)); - placementCoordinates.put(Configuration.ORIENTATION_LANDSCAPE, - extractOrientationPlacements(coordinates, displayMetrics.density, "l", content, + placementCoordinates.put(Configuration.ORIENTATION_LANDSCAPE, + extractOrientationPlacements(coordinates, displayMetrics.density, "l", content, displayOption, safeArea != null ? safeArea.landscapeTopOffset : 0, safeArea != null ? safeArea.landscapeLeftOffset : 0)); return placementCoordinates; } - private TransparentActivityConfig extractOrientationPlacements(@NonNull JSONObject placements, - float density, @NonNull String orientation, @NonNull String content, + private TransparentActivityConfig extractOrientationPlacements(@NonNull JSONObject placements, + float density, @NonNull String orientation, @NonNull String content, WebViewDisplayOption displayOption, int topOffset, int leftOffset) { if (placements.has(orientation)) { JSONObject orientationPlacements = placements.optJSONObject(orientation); @@ -277,21 +339,21 @@ private TransparentActivityConfig extractOrientationPlacements(@NonNull JSONObje int w = orientationPlacements.optInt("w"); int h = orientationPlacements.optInt("h"); L.d("[ModuleContent] extractOrientationPlacements, orientation: [" + orientation + "], x: [" + x + "], y: [" + y + "], w: [" + w + "], h: [" + h + "]"); - + int xPx = Math.round(x * density); int yPx = Math.round(y * density); int wPx = Math.round(w * density); int hPx = Math.round(h * density); L.d("[ModuleContent] extractOrientationPlacements, orientation: [" + orientation + "], converting dp->px: [" + w + "x" + h + " dp] -> [" + wPx + "x" + hPx + " px], density: [" + density + "]"); - + TransparentActivityConfig config = new TransparentActivityConfig(xPx, yPx, wPx, hPx); config.url = content; config.useSafeArea = (displayOption == WebViewDisplayOption.SAFE_AREA); config.topOffset = topOffset; config.leftOffset = leftOffset; - + L.d("[ModuleContent] extractOrientationPlacements, orientation: [" + orientation + "], created config - useSafeArea: [" + config.useSafeArea + "], topOffset: [" + config.topOffset + "], leftOffset: [" + config.leftOffset + "]"); - + return config; } @@ -327,7 +389,7 @@ private void exitContentZoneInternal() { waitForDelay = 0; } - private void refreshContentZoneInternal() { + void refreshContentZoneInternal(boolean callRQFlush) { if (!configProvider.getRefreshContentZoneEnabled()) { return; } @@ -341,9 +403,12 @@ private void refreshContentZoneInternal() { exitContentZoneInternal(); } - _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(); - - enterContentZoneInternal(null, REFRESH_CONTENT_ZONE_DELAY_MS); + if (callRQFlush) { + _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(); + enterContentZoneInternal(null, REFRESH_CONTENT_ZONE_DELAY_MS, null); + } else { + enterContentZoneWithRetriesInternal(); + } } public class Content { @@ -359,7 +424,7 @@ public void enterContentZone() { return; } - enterContentZoneInternal(null, 0); + enterContentZoneInternal(null, 0, null); } /** @@ -387,7 +452,7 @@ public void refreshContentZone() { return; } - refreshContentZoneInternal(); + refreshContentZoneInternal(true); } } } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java index c0b73246d..bea8ebb67 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java @@ -230,10 +230,27 @@ public void recordEventInternal(@Nullable final String key, @Nullable Map(); } + + // apply segmentation listing filters + UtilsListingFilters.applySegmentationFilter(segmentation, configProvider, L); + + // then apply specific event segmentation listing filters if any + UtilsListingFilters.applyEventSegmentationFilter(key, segmentation, configProvider, L); + + // apply journey trigger events here + boolean triggerRefreshContentZone = configProvider.getJourneyTriggerEvents().contains(key); + + String keyTruncated = UtilsInternalLimits.truncateKeyLength(key, _cly.config_.sdkInternalLimits.maxKeyLength, L, "[ModuleEvents] recordEventInternal"); + UtilsInternalLimits.applySdkInternalLimitsToSegmentation(segmentation, _cly.config_.sdkInternalLimits, L, "[ModuleEvents] recordEventInternal"); if (viewNameRecordingEnabled) { @@ -244,7 +261,7 @@ public void recordEventInternal(@Nullable final String key, @Nullable Map { - if (preflightResponse == null) { - L.e("[ModuleFeedback] Failed to do preflight check for the widget url"); - if (devCallback != null) { - devCallback.onFinished("Failed to do preflight check for the widget url"); + if (!Utils.isNullOrEmpty(widgetInfo.widgetVersion)) { + L.d("[ModuleFeedback] Will use transparent activity for displaying the widget"); + showFeedbackWidget_newActivity(context, preparedWidgetUrl, widgetInfo, devCallback); + } else { + iRGenerator.CreatePreflightRequestMaker().doWork(preparedWidgetUrl, null, requestQueueProvider.createConnectionProcessor(), false, true, preflightResponse -> { + if (preflightResponse == null) { + L.e("[ModuleFeedback] Failed to do preflight check for the widget url"); + if (devCallback != null) { + devCallback.onFinished("Failed to do preflight check for the widget url"); + } + return; } - return; - } - - if (!Utils.isNullOrEmpty(widgetInfo.widgetVersion)) { - L.d("[ModuleFeedback] Will use transparent activity for displaying the widget"); - showFeedbackWidget_newActivity(context, preparedWidgetUrl, widgetInfo, devCallback); - } else { L.d("[ModuleFeedback] Will use dialog for displaying the widget"); //enable for chrome debugging // WebView.setWebContentsDebuggingEnabled(true); @@ -305,8 +304,8 @@ public void run() { } } }); - } - }, L); + }, L); + } } private void showFeedbackWidget(Context context, CountlyFeedbackWidget widgetInfo, String closeButtonText, FeedbackCallback devCallback, String url) { @@ -360,7 +359,7 @@ private void showFeedbackWidget_newActivity(@NonNull Context context, String url if (displayOption == WebViewDisplayOption.SAFE_AREA) { L.d("[ModuleFeedback] showFeedbackWidget_newActivity, calculating safe area dimensions..."); SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(context, L); - + portraitWidth = safeArea.portraitWidth; portraitHeight = safeArea.portraitHeight; landscapeWidth = safeArea.landscapeWidth; @@ -369,7 +368,7 @@ private void showFeedbackWidget_newActivity(@NonNull Context context, String url landscapeTopOffset = safeArea.landscapeTopOffset; portraitLeftOffset = safeArea.portraitLeftOffset; landscapeLeftOffset = safeArea.landscapeLeftOffset; - + L.d("[ModuleFeedback] showFeedbackWidget_newActivity, safe area dimensions (px) - Portrait: [" + portraitWidth + "x" + portraitHeight + "], topOffset: [" + portraitTopOffset + "], leftOffset: [" + portraitLeftOffset + "]"); L.d("[ModuleFeedback] showFeedbackWidget_newActivity, safe area dimensions (px) - Landscape: [" + landscapeWidth + "x" + landscapeHeight + "], topOffset: [" + landscapeTopOffset + "], leftOffset: [" + landscapeLeftOffset + "]"); } else { @@ -380,7 +379,7 @@ private void showFeedbackWidget_newActivity(@NonNull Context context, String url portraitHeight = portrait ? height : width; landscapeWidth = portrait ? height : width; landscapeHeight = portrait ? width : height; - + L.d("[ModuleFeedback] showFeedbackWidget_newActivity, using immersive mode (full screen) dimensions (px) - Portrait: [" + portraitWidth + "x" + portraitHeight + "], Landscape: [" + landscapeWidth + "x" + landscapeHeight + "]"); } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java b/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java index d446bee4e..427eb4ae9 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java @@ -152,11 +152,30 @@ synchronized List requestQueueRemoveWithoutAppKey(String[] storedRequest * They will be sent either if the exceed the Threshold size or if their sending is forced */ protected void sendEventsIfNeeded(boolean forceSendingEvents) { + sendEventsIfNeeded(forceSendingEvents, false); + } + + /** + * Check if events from event queue need to be added to the request queue + * They will be sent either if they exceed the Threshold size or if their sending is forced + */ + protected void sendEventsIfNeeded(boolean forceSendingEvents, boolean triggerRefreshContentZone) { int eventsInEventQueue = storageProvider.getEventQueueSize(); L.v("[ModuleRequestQueue] forceSendingEvents, forced:[" + forceSendingEvents + "], event count:[" + eventsInEventQueue + "]"); + InternalRequestCallback callback = null; + if (triggerRefreshContentZone) { + callback = new InternalRequestCallback() { + @Override public void onRequestCompleted(String response, boolean success) { + if (success) { + _cly.moduleContent.refreshContentZoneInternal(false); + } + } + }; + } + if ((forceSendingEvents && eventsInEventQueue > 0) || eventsInEventQueue >= _cly.EVENT_QUEUE_SIZE_THRESHOLD) { - requestQueueProvider.recordEvents(storageProvider.getEventsForRequestAndEmptyEventQueue()); + requestQueueProvider.recordEvents(storageProvider.getEventsForRequestAndEmptyEventQueue(), callback); } } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java b/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java index 24ddb01f4..811341a1c 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java @@ -60,6 +60,8 @@ void beginSessionInternal() { String preparedMetrics = deviceInfo.getMetrics(_cly.context_, metricOverride, L); sessionRunning = true; prevSessionDurationStartTime_ = System.currentTimeMillis(); + _cly.moduleUserProfile.saveInternal(); + requestQueueProvider.beginSession(_cly.moduleLocation.locationDisabled, _cly.moduleLocation.locationCountryCode, _cly.moduleLocation.locationCity, _cly.moduleLocation.locationGpsCoordinates, _cly.moduleLocation.locationIpAddress, preparedMetrics); if (_cly.moduleViews.trackOrientationChanges) { @@ -85,6 +87,8 @@ void updateSessionInternal() { } if (!_cly.disableUpdateSessionRequests_) { + _cly.moduleUserProfile.saveInternal(); + requestQueueProvider.updateSession(roundedSecondsSinceLastSessionDurationUpdate()); } } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java index 30922b146..15e8cf7c8 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java @@ -219,6 +219,12 @@ void modifyCustomData(String key, Object value, String mod) { return; } + // apply user property filter + if (!UtilsListingFilters.applyUserPropertyFilter(key, configProvider)) { + L.w("[ModuleUserProfile] modifyCustomData, key: [" + key + "] is filtered out by user property filter, omitting call"); + return; + } + Object valueAdded; String truncatedKey = UtilsInternalLimits.truncateKeyLength(key, _cly.config_.sdkInternalLimits.maxKeyLength, _cly.L, "[ModuleUserProfile] modifyCustomData"); if (value instanceof String) { @@ -246,7 +252,10 @@ void modifyCustomData(String key, Object value, String mod) { } ob.accumulate(mod, valueAdded); } + customMods.put(truncatedKey, ob); + applyUserPropertyCacheLimit(customMods); + isSynced = false; } catch (JSONException e) { e.printStackTrace(); @@ -299,6 +308,12 @@ void setPropertiesInternal(@NonNull Map data) { } if (!isNamed) { + // user property filter + if (!UtilsListingFilters.applyUserPropertyFilter(key, configProvider)) { + L.w("[ModuleUserProfile] setPropertiesInternal, key: [" + key + "] is filtered out by user property filter, omitting call"); + continue; + } + String truncatedKey = UtilsInternalLimits.truncateKeyLength(key, _cly.config_.sdkInternalLimits.maxKeyLength, _cly.L, "[ModuleUserProfile] setPropertiesInternal"); if (UtilsInternalLimits.isSupportedDataType(value)) { dataCustomFields.put(truncatedKey, value); @@ -317,10 +332,22 @@ void setPropertiesInternal(@NonNull Map data) { } custom.putAll(dataCustomFields); + applyUserPropertyCacheLimit(custom); isSynced = false; } + private void applyUserPropertyCacheLimit(Map map) { + int cacheLimit = configProvider.getUserPropertyCacheLimit(); + while (map.size() > cacheLimit) { + Iterator iterator = map.keySet().iterator(); + if (iterator.hasNext()) { + iterator.next(); + iterator.remove(); + } + } + } + /** * Sets user data values. * diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java b/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java index 05da30220..69d3d9b40 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java @@ -20,6 +20,7 @@ public class ModuleViews extends ModuleBase implements ViewIdProvider { String currentViewName = ""; private boolean firstView = true; boolean autoViewTracker = false; + boolean restartManualViews = true; boolean automaticTrackingShouldUseShortName = false; //track orientation changes @@ -84,6 +85,7 @@ static class ViewData { setGlobalViewSegmentationInternal(config.globalViewSegmentation); autoTrackingActivityExceptions = config.automaticViewTrackingExceptions; trackOrientationChanges = config.trackOrientationChange; + restartManualViews = !config.disableViewRestartForManualRecording; viewsInterface = new Views(); } @@ -552,7 +554,7 @@ void onActivityStopped(int updatedActivityCount) { } } - if (updatedActivityCount <= 0) { + if (updatedActivityCount <= 0 && (autoViewTracker || restartManualViews)) { //if we go to the background, stop all running views stopRunningViewsAndSend(); } @@ -587,8 +589,7 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { } } - if (updatedActivityCount == 1) { - //if we go to the background, stop all running views + if (updatedActivityCount == 1 && (autoViewTracker || restartManualViews)) { startStoppedViews(); } } diff --git a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java index 19fa17936..f9bea9977 100644 --- a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java @@ -29,6 +29,8 @@ interface RequestQueueProvider { void recordEvents(final String events); + void recordEvents(final String events, @Nullable InternalRequestCallback callback); + void sendConsentChanges(String formattedConsentChanges); void sendAPMCustomTrace(String key, Long durationMs, Long startMs, Long endMs, String customMetrics); diff --git a/sdk/src/main/java/ly/count/android/sdk/TransparentActivity.java b/sdk/src/main/java/ly/count/android/sdk/TransparentActivity.java index a510e3e72..a5d1a4d73 100644 --- a/sdk/src/main/java/ly/count/android/sdk/TransparentActivity.java +++ b/sdk/src/main/java/ly/count/android/sdk/TransparentActivity.java @@ -54,9 +54,23 @@ protected void onCreate(Bundle savedInstanceState) { currentOrientation = intent.getIntExtra(ORIENTATION, 0); configLandscape = (TransparentActivityConfig) intent.getSerializableExtra(CONFIGURATION_LANDSCAPE); configPortrait = (TransparentActivityConfig) intent.getSerializableExtra(CONFIGURATION_PORTRAIT); + Log.v(Countly.TAG, "[TransparentActivity] onCreate, orientation: " + currentOrientation); - Log.v(Countly.TAG, "[TransparentActivity] onCreate, configLandscape x: [" + configLandscape.x + "] y: [" + configLandscape.y + "] width: [" + configLandscape.width + "] height: [" + configLandscape.height + "], topOffset: [" + configLandscape.topOffset + "], leftOffset: [" + configLandscape.leftOffset + "]"); - Log.v(Countly.TAG, "[TransparentActivity] onCreate, configPortrait x: [" + configPortrait.x + "] y: [" + configPortrait.y + "] width: [" + configPortrait.width + "] height: [" + configPortrait.height + "], topOffset: [" + configPortrait.topOffset + "], leftOffset: [" + configPortrait.leftOffset + "]"); + Log.v(Countly.TAG, "[TransparentActivity] onCreate, configLandscape x: [" + + configLandscape.x + + "] y: [" + + configLandscape.y + + "] width: [" + + configLandscape.width + + "] height: [" + + configLandscape.height + + "], topOffset: [" + + configLandscape.topOffset + + "], leftOffset: [" + + configLandscape.leftOffset + + "]"); + Log.v(Countly.TAG, + "[TransparentActivity] onCreate, configPortrait x: [" + configPortrait.x + "] y: [" + configPortrait.y + "] width: [" + configPortrait.width + "] height: [" + configPortrait.height + "], topOffset: [" + configPortrait.topOffset + "], leftOffset: [" + configPortrait.leftOffset + "]"); TransparentActivityConfig config; if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) { @@ -70,10 +84,10 @@ protected void onCreate(Bundle savedInstanceState) { // Configure window layout parameters WindowManager.LayoutParams params = new WindowManager.LayoutParams(); params.gravity = Gravity.TOP | Gravity.START; // try out START - + int adjustedX = config.x; int adjustedY = config.y; - + if (config.useSafeArea) { if (config.leftOffset > 0) { adjustedX += config.leftOffset; @@ -84,19 +98,19 @@ protected void onCreate(Bundle savedInstanceState) { Log.d(Countly.TAG, "[TransparentActivity] onCreate, using safe area mode, adjusting y from [" + config.y + "] to [" + adjustedY + "] (topOffset: " + config.topOffset + ")"); } } - + params.x = adjustedX; params.y = adjustedY; - + params.height = config.height; params.width = config.width; - params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL - | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH; + params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; getWindow().setAttributes(params); - + WindowManager.LayoutParams verifyParams = getWindow().getAttributes(); Log.d(Countly.TAG, "[TransparentActivity] onCreate, AFTER setAttributes - params.x: [" + verifyParams.x + "], params.y: [" + verifyParams.y + "], params.gravity: [" + verifyParams.gravity + "], width: [" + verifyParams.width + "], height: [" + verifyParams.height + "]"); - + getWindow().setBackgroundDrawableResource(android.R.color.transparent); // Create and configure the layout @@ -119,7 +133,7 @@ private TransparentActivityConfig setupConfig(@Nullable TransparentActivityConfi if (!config.useSafeArea) { final DisplayMetrics metrics = UtilsDevice.getDisplayMetrics(this); - + if (config.width < 1) { config.width = metrics.widthPixels; } @@ -127,24 +141,25 @@ private TransparentActivityConfig setupConfig(@Nullable TransparentActivityConfi config.height = metrics.heightPixels; } } - + if (config.x < 1) { config.x = 0; } if (config.y < 1) { config.y = 0; } - + Log.d(Countly.TAG, "[TransparentActivity] setupConfig, final config - x: [" + config.x + "], y: [" + config.y + "], width: [" + config.width + "], height: [" + config.height + "], useSafeArea: [" + config.useSafeArea + "]"); return config; } private void resizeContent(TransparentActivityConfig config) { - Log.d(Countly.TAG, "[TransparentActivity] resizeContent(config), config dimensions (px): [" + config.width + "x" + config.height + "], x: [" + config.x + "], y: [" + config.y + "], useSafeArea: [" + config.useSafeArea + "], topOffset: [" + config.topOffset + "], leftOffset: [" + config.leftOffset + "]"); - + Log.d(Countly.TAG, + "[TransparentActivity] resizeContent(config), config dimensions (px): [" + config.width + "x" + config.height + "], x: [" + config.x + "], y: [" + config.y + "], useSafeArea: [" + config.useSafeArea + "], topOffset: [" + config.topOffset + "], leftOffset: [" + config.leftOffset + "]"); + int adjustedX = config.x; int adjustedY = config.y; - + if (config.useSafeArea) { if (config.leftOffset > 0) { adjustedX += config.leftOffset; @@ -155,7 +170,7 @@ private void resizeContent(TransparentActivityConfig config) { Log.d(Countly.TAG, "[TransparentActivity] resizeContent(config), applying top offset, adjusted y: [" + adjustedY + "]"); } } - + WindowManager.LayoutParams params = getWindow().getAttributes(); Log.d(Countly.TAG, "[TransparentActivity] resizeContent(config), BEFORE - params.x: [" + params.x + "], params.y: [" + params.y + "], params.gravity: [" + params.gravity + "]"); params.gravity = Gravity.TOP | Gravity.START; // safe? @@ -164,7 +179,7 @@ private void resizeContent(TransparentActivityConfig config) { params.height = config.height; params.width = config.width; getWindow().setAttributes(params); - + WindowManager.LayoutParams verifyParams = getWindow().getAttributes(); Log.d(Countly.TAG, "[TransparentActivity] resizeContent(config), AFTER - params.x: [" + verifyParams.x + "], params.y: [" + verifyParams.y + "], params.gravity: [" + verifyParams.gravity + "], width: [" + verifyParams.width + "], height: [" + verifyParams.height + "]"); @@ -177,7 +192,7 @@ private void resizeContent(TransparentActivityConfig config) { webLayoutParams.width = config.width; webLayoutParams.height = config.height; webView.setLayoutParams(webLayoutParams); - + Log.d(Countly.TAG, "[TransparentActivity] resizeContent(config), layout params set - relativeLayout: [" + layoutParams.width + "x" + layoutParams.height + "], webView: [" + webLayoutParams.width + "x" + webLayoutParams.height + "]"); } @@ -195,23 +210,23 @@ public void onConfigurationChanged(android.content.res.Configuration newConfig) private void resizeContent() { TransparentActivityConfig currentConfig = (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) ? configLandscape : configPortrait; - + float density = getResources().getDisplayMetrics().density; int widthPx, heightPx; - + if (currentConfig != null && currentConfig.useSafeArea) { Log.d(Countly.TAG, "[TransparentActivity] resizeContent, recalculating safe area dimensions for orientation change"); - + SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(this, Countly.sharedInstance().L); - + configPortrait.topOffset = safeArea.portraitTopOffset; configPortrait.leftOffset = safeArea.portraitLeftOffset; configLandscape.topOffset = safeArea.landscapeTopOffset; configLandscape.leftOffset = safeArea.landscapeLeftOffset; - + Log.d(Countly.TAG, "[TransparentActivity] resizeContent, updated offsets - Portrait: topOffset=[" + configPortrait.topOffset + "], leftOffset=[" + configPortrait.leftOffset + "]"); Log.d(Countly.TAG, "[TransparentActivity] resizeContent, updated offsets - Landscape: topOffset=[" + configLandscape.topOffset + "], leftOffset=[" + configLandscape.leftOffset + "]"); - + int topOffset, leftOffset; if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) { widthPx = safeArea.landscapeWidth; @@ -224,16 +239,30 @@ private void resizeContent() { topOffset = safeArea.portraitTopOffset; leftOffset = safeArea.portraitLeftOffset; } - - Log.d(Countly.TAG, "[TransparentActivity] resizeContent, safe area mode - sending dimensions to webview (px): [" + widthPx + "x" + heightPx + "], (dp): [" + Math.round(widthPx / density) + "x" + Math.round(heightPx / density) + "], density: [" + density + "], topOffset: [" + topOffset + "], leftOffset: [" + leftOffset + "]"); + + Log.d(Countly.TAG, "[TransparentActivity] resizeContent, safe area mode - sending dimensions to webview (px): [" + + widthPx + + "x" + + heightPx + + "], (dp): [" + + Math.round(widthPx / density) + + "x" + + Math.round(heightPx / density) + + "], density: [" + + density + + "], topOffset: [" + + topOffset + + "], leftOffset: [" + + leftOffset + + "]"); } else { final DisplayMetrics metrics = UtilsDevice.getDisplayMetrics(this); widthPx = metrics.widthPixels; heightPx = metrics.heightPixels; - + Log.d(Countly.TAG, "[TransparentActivity] resizeContent, immersive mode - sending dimensions to webview (px): [" + widthPx + "x" + heightPx + "], (dp): [" + Math.round(widthPx / density) + "x" + Math.round(heightPx / density) + "], density: [" + density + "]"); } - + int scaledWidth = Math.round(widthPx / density); int scaledHeight = Math.round(heightPx / density); webView.loadUrl("javascript:window.postMessage({type: 'resize', width: " + scaledWidth + ", height: " + scaledHeight + "}, '*');"); @@ -257,6 +286,14 @@ private void hideSystemUI() { | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN); + + getWindow().setNavigationBarColor(Color.TRANSPARENT); + getWindow().setStatusBarColor(Color.TRANSPARENT); + + getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); } private void resizeContentInternal() { @@ -292,7 +329,9 @@ private boolean widgetUrlAction(String url, WebView view) { close(query); ModuleFeedback.CountlyFeedbackWidget widgetInfo = (ModuleFeedback.CountlyFeedbackWidget) getIntent().getSerializableExtra(WIDGET_INFO); - Countly.sharedInstance().moduleFeedback.reportFeedbackWidgetCancelButton(widgetInfo); + if (widgetInfo != null) { + Countly.sharedInstance().moduleFeedback.reportFeedbackWidgetCancelButton(widgetInfo); + } } } @@ -379,14 +418,14 @@ private void resizeMeAction(Map query) { Log.v(Countly.TAG, "[TransparentActivity] resizeMeAction, resize_me JSON: [" + resizeMeJson + "]"); JSONObject portrait = resizeMeJson.getJSONObject("p"); JSONObject landscape = resizeMeJson.getJSONObject("l"); - + boolean portraitUseSafeArea = configPortrait.useSafeArea; boolean landscapeUseSafeArea = configLandscape.useSafeArea; int portraitTopOffset = configPortrait.topOffset; int landscapeTopOffset = configLandscape.topOffset; int portraitLeftOffset = configPortrait.leftOffset; int landscapeLeftOffset = configLandscape.leftOffset; - + configPortrait.x = (int) Math.ceil(portrait.getInt("x") * density); configPortrait.y = (int) Math.ceil(portrait.getInt("y") * density); configPortrait.width = (int) Math.ceil(portrait.getInt("w") * density); @@ -402,7 +441,7 @@ private void resizeMeAction(Map query) { configLandscape.useSafeArea = landscapeUseSafeArea; configLandscape.topOffset = landscapeTopOffset; configLandscape.leftOffset = landscapeLeftOffset; - + Log.d(Countly.TAG, "[TransparentActivity] resizeMeAction, updated configs - Portrait: useSafeArea=[" + portraitUseSafeArea + "], topOffset=[" + portraitTopOffset + "], leftOffset=[" + portraitLeftOffset + "]"); Log.d(Countly.TAG, "[TransparentActivity] resizeMeAction, updated configs - Landscape: useSafeArea=[" + landscapeUseSafeArea + "], topOffset=[" + landscapeTopOffset + "], leftOffset=[" + landscapeLeftOffset + "]"); @@ -526,14 +565,19 @@ private WebView createWebView(TransparentActivityConfig config) { } }); client.afterPageFinished = new WebViewPageLoadedListener() { - @Override public void onPageLoaded(boolean timedOut) { - if (timedOut) { + @Override public void onPageLoaded(boolean failed) { + if (failed) { close(new HashMap<>()); if (Countly.sharedInstance().isInitialized()) { Countly.sharedInstance().moduleContent.notifyAfterContentIsClosed(); } } else { + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH); + TransparentActivityConfig currentConfig = currentOrientation == Configuration.ORIENTATION_LANDSCAPE ? configLandscape : configPortrait; if (currentConfig != null && !currentConfig.useSafeArea) { hideSystemUI(); diff --git a/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java b/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java index e3a93329f..cc155d0ac 100644 --- a/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java +++ b/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java @@ -50,14 +50,6 @@ private static void applyWindowMetrics(@NonNull Context context, // If not activity, we can't know system UI visibility, so always use physical screen size if (!usePhysicalScreenSize) { - // Only subtract navigation bar insets when navigation bar is actually visible - if (windowInsets.isVisible(WindowInsets.Type.navigationBars())) { - types |= WindowInsets.Type.navigationBars(); - } - - if (windowInsets.isVisible(WindowInsets.Type.statusBars())) { - types |= WindowInsets.Type.statusBars(); - } boolean drawUnderCutout; WindowManager.LayoutParams params = ((Activity) context).getWindow().getAttributes(); diff --git a/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java b/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java new file mode 100644 index 000000000..763bfff4a --- /dev/null +++ b/sdk/src/main/java/ly/count/android/sdk/UtilsListingFilters.java @@ -0,0 +1,75 @@ +package ly.count.android.sdk; + +import androidx.annotation.NonNull; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +final class UtilsListingFilters { + + private UtilsListingFilters() { + } + + static boolean applyEventFilter(@NonNull String eventName, @NonNull ConfigurationProvider configProvider) { + ConfigurationProvider.FilterList> eventFilterList = configProvider.getEventFilterList(); + return applyListFilter(eventName, eventFilterList.filterList, eventFilterList.isWhitelist); + } + + static boolean applyUserPropertyFilter(@NonNull String propertyName, @NonNull ConfigurationProvider configProvider) { + ConfigurationProvider.FilterList> userPropertyFilterList = configProvider.getUserPropertyFilterList(); + return applyListFilter(propertyName, userPropertyFilterList.filterList, userPropertyFilterList.isWhitelist); + } + + static void applySegmentationFilter(@NonNull Map segmentation, @NonNull ConfigurationProvider configProvider, @NonNull ModuleLog L) { + if (segmentation.isEmpty()) { + return; + } + + applyMapFilter(segmentation, configProvider.getSegmentationFilterList().filterList, configProvider.getSegmentationFilterList().isWhitelist, L); + } + + static void applyEventSegmentationFilter(@NonNull String eventName, @NonNull Map segmentation, + @NonNull ConfigurationProvider configProvider, @NonNull ModuleLog L) { + ConfigurationProvider.FilterList>> eventSegmentationFilterList = configProvider.getEventSegmentationFilterList(); + if (segmentation.isEmpty() || eventSegmentationFilterList.filterList.isEmpty()) { + return; + } + + Set segmentationSet = eventSegmentationFilterList.filterList.get(eventName); + if (segmentationSet == null || segmentationSet.isEmpty()) { + // No rules defined for this event so allow everything + return; + } + applyMapFilter(segmentation, segmentationSet, eventSegmentationFilterList.isWhitelist, L); + } + + private static void applyMapFilter(@NonNull Map map, @NonNull Set filterSet, boolean isWhitelist, @NonNull ModuleLog L) { + if (filterSet.isEmpty()) { + // No rules defined so allow everything + return; + } + + Iterator> iterator = map.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + String key = entry.getKey(); + + boolean contains = filterSet.contains(key); + + // Whitelist: remove if NOT in list + // Blacklist: remove if IN list + if ((isWhitelist && !contains) || (!isWhitelist && contains)) { + iterator.remove(); + L.d("[UtilsListingFilters] applyMapFilter, removed key: " + key + (isWhitelist ? "not in whitelist" : "blacklisted")); + } + } + } + + private static boolean applyListFilter(String item, @NonNull Set filterSet, boolean isWhitelist) { + if (filterSet.isEmpty()) { + // No rules defined so allow everything + return true; + } + return isWhitelist == filterSet.contains(item); + } +} diff --git a/sdk/src/main/java/ly/count/android/sdk/WebViewPageLoadedListener.java b/sdk/src/main/java/ly/count/android/sdk/WebViewPageLoadedListener.java index 86d6b2cc3..417ffd510 100644 --- a/sdk/src/main/java/ly/count/android/sdk/WebViewPageLoadedListener.java +++ b/sdk/src/main/java/ly/count/android/sdk/WebViewPageLoadedListener.java @@ -2,5 +2,5 @@ public interface WebViewPageLoadedListener { - void onPageLoaded(boolean timedOut); + void onPageLoaded(boolean failed); }