From 89d53c12670b2f5af5ba7d0da097fb230be745bc Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 26 Jan 2026 12:45:09 +0300 Subject: [PATCH 01/12] feat: internal RQ callback --- .../ly/count/android/sdk/ConnectionQueue.java | 56 +++++++++++-------- .../android/sdk/InternalRequestCallback.java | 4 ++ 2 files changed, 37 insertions(+), 23 deletions(-) create mode 100644 sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java 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..e3d0c4d5f 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -26,6 +26,8 @@ of this software and associated documentation files (the "Software"), to deal import androidx.annotation.Nullable; import java.util.Arrays; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -70,6 +72,7 @@ class ConnectionQueue implements RequestQueueProvider { StorageProvider storageProvider; ConfigurationProvider configProvider; RequestInfoProvider requestInfoProvider; + private Map internalRequestCallbacks = new ConcurrentHashMap<>(); void setBaseInfoProvider(BaseInfoProvider bip) { baseInfoProvider = bip; @@ -208,7 +211,7 @@ public void beginSession(boolean locationDisabled, @Nullable String locationCoun Countly.sharedInstance().isBeginSessionSent = true; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -233,7 +236,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 +263,7 @@ public void exitForKeys(@NonNull String[] keys) { data += "&keys=" + UtilsNetworking.encodedArrayBuilder(keys); } - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -286,7 +289,7 @@ public void updateSession(final int duration) { String data = prepareCommonRequestData(); data += "&session_duration=" + duration; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } } @@ -301,7 +304,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 +333,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 +359,7 @@ public void endSession(final int duration) { data += "&session_duration=" + duration; } - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -373,7 +376,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 +398,7 @@ public void sendUserData(String userdata) { } String data = prepareCommonRequestData() + userdata; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -418,7 +421,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 +445,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 +476,7 @@ public void sendDirectAttributionLegacy(@NonNull String campaignID, @Nullable St } String data = prepareCommonRequestData() + res; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -498,7 +501,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 +538,7 @@ public void sendDirectRequest(@NonNull final Map requestData) { )); } - addRequestToQueue(data.toString(), false); + addRequestToQueue(data.toString(), false, null); tick(); } @@ -546,7 +549,7 @@ public void sendMetricsRequest(@NonNull String preparedMetrics) { } L.d("[ConnectionQueue] sendMetricsRequest"); - addRequestToQueue(prepareCommonRequestData() + "&metrics=" + preparedMetrics, false); + addRequestToQueue(prepareCommonRequestData() + "&metrics=" + preparedMetrics, false, null); tick(); } @@ -569,7 +572,7 @@ public void recordEvents(final String events) { final String data = prepareCommonRequestData() + "&events=" + events; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -582,7 +585,7 @@ public void sendConsentChanges(String formattedConsentChanges) { final String data = prepareCommonRequestData() + "&consent=" + UtilsNetworking.urlEncodeString(formattedConsentChanges); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -609,7 +612,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 +640,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 +665,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 +690,7 @@ public void sendAPMScreenTime(boolean recordForegroundTime, long durationMs, Lon + "&count=1" + "&apm=" + UtilsNetworking.urlEncodeString(apmData); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -944,8 +947,15 @@ public boolean queueContainsTemporaryIdItems() { return false; } - void addRequestToQueue(final @NonNull String requestData, final boolean writeInSync) { - storageProvider.addRequest(requestData, writeInSync); + 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); + } } /** 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..86e9537e7 --- /dev/null +++ b/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java @@ -0,0 +1,4 @@ +package ly.count.android.sdk; + +interface InternalRequestCallback { +} From 07ea3b8fef12dbe6739844972b1ee56e92b74d40 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 26 Jan 2026 12:55:35 +0300 Subject: [PATCH 02/12] feat: integrate callback basic --- .../ly/count/android/sdk/ConnectionProcessorTests.java | 7 ++++--- .../ly/count/android/sdk/CustomHeaderRuntimeTests.java | 5 ++++- .../java/ly/count/android/sdk/ConnectionProcessor.java | 4 +++- .../main/java/ly/count/android/sdk/ConnectionQueue.java | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java index 03cf9137e..539d3cd01 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java @@ -31,6 +31,7 @@ of this software and associated documentation files (the "Software"), to deal import java.net.URLConnection; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -161,7 +162,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 +171,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 +238,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/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/main/java/ly/count/android/sdk/ConnectionProcessor.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java index 9d9a21d0b..4c7cf5f9c 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java @@ -64,6 +64,7 @@ public class ConnectionProcessor implements Runnable { private final Map requestHeaderCustomValues_; private final Runnable backoffCallback_; + private final Map internalRequestCallbacks_; static String endPointOverrideTag = "&new_end_point="; @@ -78,7 +79,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 +88,7 @@ private enum RequestResult { requestHeaderCustomValues_ = requestHeaderCustomValues; requestInfoProvider_ = requestInfoProvider; backoffCallback_ = backoffCallback; + internalRequestCallbacks_ = internalRequestCallbacks; L = logModule; this.healthTracker = healthTracker; } 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 e3d0c4d5f..c1903511c 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -929,7 +929,7 @@ public void run() { } }, configProvider.getBOMDuration(), TimeUnit.SECONDS); } - }); + }, internalRequestCallbacks); cp.pcc = pcc; return cp; } From 9e97fbc5f29a4d41ac69a5de6e2090f4bf22f3be Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 26 Jan 2026 12:59:27 +0300 Subject: [PATCH 03/12] feat: callback impl --- .../java/ly/count/android/sdk/InternalRequestCallback.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java b/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java index 86e9537e7..07259fab5 100644 --- a/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java +++ b/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java @@ -1,4 +1,9 @@ package ly.count.android.sdk; interface InternalRequestCallback { + default void onRequestCompleted(String response, boolean success) { + } + + default void onRQFinished() { + } } From c781349edd4ca06cc8d65be68c5008aa46a76285 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 26 Jan 2026 13:32:08 +0300 Subject: [PATCH 04/12] feat: callback impl inside RQ --- .../count/android/sdk/ConnectionProcessor.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 4c7cf5f9c..8046e71a4 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java @@ -462,6 +462,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); } @@ -581,6 +590,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); @@ -590,6 +603,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 From 52de48883be63ad94dc4db22a1f71486a517aa90 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 26 Jan 2026 14:03:55 +0300 Subject: [PATCH 05/12] feat: callback on error --- .../main/java/ly/count/android/sdk/ConnectionProcessor.java | 4 ++++ 1 file changed, 4 insertions(+) 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 8046e71a4..d5a3d99f6 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java @@ -620,6 +620,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); From aebbe784652f315bf30bc17efee5aa4cb30af8e2 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 26 Jan 2026 14:44:10 +0300 Subject: [PATCH 06/12] feat: global rq callback --- .../android/sdk/ConnectionProcessor.java | 6 ++++-- .../ly/count/android/sdk/ConnectionQueue.java | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) 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 d5a3d99f6..688761e4f 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_; @@ -65,7 +64,6 @@ 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; @@ -393,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; } 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 c1903511c..48a84e9ed 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -24,7 +24,9 @@ of this software and associated documentation files (the "Software"), to deal import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -50,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_; @@ -72,7 +75,8 @@ class ConnectionQueue implements RequestQueueProvider { StorageProvider storageProvider; ConfigurationProvider configProvider; RequestInfoProvider requestInfoProvider; - private Map internalRequestCallbacks = new ConcurrentHashMap<>(); + private final Map internalRequestCallbacks = new ConcurrentHashMap<>(); + private final List internalGlobalRequestCallbackActions = new ArrayList<>(); void setBaseInfoProvider(BaseInfoProvider bip) { baseInfoProvider = bip; @@ -94,6 +98,16 @@ void setContext(final Context context) { context_ = context; } + public ConnectionQueue() { + internalRequestCallbacks.put(GLOBAL_RC_CALLBACK, new InternalRequestCallback() { + @Override public void onRQFinished() { + for (Runnable r : internalGlobalRequestCallbackActions) { + r.run(); + } + } + }); + } + void setupSSLContext() { if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) { sslContext_ = null; @@ -958,6 +972,10 @@ void addRequestToQueue(final @NonNull String requestData, final boolean writeInS } } + void registerInternalGlobalRequestCallbackAction(Runnable runnable) { + internalGlobalRequestCallbackActions.add(runnable); + } + /** * Returns true if no requests are current stored, false otherwise. */ From cfd4f18344c0ea92f48fd75efe09f986be78858c Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 26 Jan 2026 14:49:00 +0300 Subject: [PATCH 07/12] feat: remove actions after called --- .../main/java/ly/count/android/sdk/ConnectionQueue.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 48a84e9ed..3a7e24644 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -26,6 +26,7 @@ of this software and associated documentation files (the "Software"), to deal import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.UUID; @@ -101,8 +102,11 @@ void setContext(final Context context) { public ConnectionQueue() { internalRequestCallbacks.put(GLOBAL_RC_CALLBACK, new InternalRequestCallback() { @Override public void onRQFinished() { - for (Runnable r : internalGlobalRequestCallbackActions) { - r.run(); + Iterator iter = internalGlobalRequestCallbackActions.iterator(); + while (iter.hasNext()) { + Runnable action = iter.next(); + action.run(); + iter.remove(); } } }); From d21430d7e3ca1f1c87a85572da98e6e580ab878f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 26 Jan 2026 14:51:17 +0300 Subject: [PATCH 08/12] feat: remove actions after called flush --- .../java/ly/count/android/sdk/ConnectionQueue.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 3a7e24644..46e58fd6f 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -26,7 +26,6 @@ of this software and associated documentation files (the "Software"), to deal import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.UUID; @@ -102,11 +101,8 @@ void setContext(final Context context) { public ConnectionQueue() { internalRequestCallbacks.put(GLOBAL_RC_CALLBACK, new InternalRequestCallback() { @Override public void onRQFinished() { - Iterator iter = internalGlobalRequestCallbackActions.iterator(); - while (iter.hasNext()) { - Runnable action = iter.next(); - action.run(); - iter.remove(); + for (Runnable r : internalGlobalRequestCallbackActions) { + r.run(); } } }); @@ -980,6 +976,10 @@ void registerInternalGlobalRequestCallbackAction(Runnable runnable) { internalGlobalRequestCallbackActions.add(runnable); } + void flushInternalGlobalRequestCallbackActions() { + internalGlobalRequestCallbackActions.clear(); + } + /** * Returns true if no requests are current stored, false otherwise. */ From 5567ea2605d3152a8ff6cd7a8f8a80b16ca318b2 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 30 Jan 2026 09:53:49 +0300 Subject: [PATCH 09/12] feat: improve the comments --- .../ly/count/android/sdk/ConnectionQueue.java | 44 +++++++++++++++++-- .../android/sdk/InternalRequestCallback.java | 41 +++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) 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 46e58fd6f..1fa0927a0 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -24,12 +24,12 @@ of this software and associated documentation files (the "Software"), to deal import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import java.util.ArrayList; 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; @@ -76,7 +76,8 @@ class ConnectionQueue implements RequestQueueProvider { ConfigurationProvider configProvider; RequestInfoProvider requestInfoProvider; private final Map internalRequestCallbacks = new ConcurrentHashMap<>(); - private final List internalGlobalRequestCallbackActions = new ArrayList<>(); + // 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; @@ -99,10 +100,18 @@ void setContext(final 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) { - r.run(); + try { + r.run(); + } catch (Exception e) { + if (L != null) { + L.e("[ConnectionQueue] Exception while executing global request callback action: " + e.getMessage()); + } + } } } }); @@ -961,6 +970,18 @@ public boolean queueContainsTemporaryIdItems() { return false; } + /** + * 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); @@ -972,10 +993,27 @@ void addRequestToQueue(final @NonNull String requestData, final boolean writeInS } } + /** + * 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/InternalRequestCallback.java b/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java index 07259fab5..baf22cc3b 100644 --- a/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java +++ b/sdk/src/main/java/ly/count/android/sdk/InternalRequestCallback.java @@ -1,9 +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() { } } From 153e31c393fb6ba4a8a17a2644204b34f37e8c75 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 30 Jan 2026 09:55:51 +0300 Subject: [PATCH 10/12] fix: call callback in old requests --- .../java/ly/count/android/sdk/ConnectionProcessor.java | 7 +++++++ 1 file changed, 7 insertions(+) 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 688761e4f..0b6646345 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java @@ -657,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); } From a53f379729518bbb90304a5bebd8a45ab18fb440 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 30 Jan 2026 15:17:17 +0300 Subject: [PATCH 11/12] feat: tests --- .../sdk/ConnectionQueueIntegrationTests.java | 608 ++++++++++++ .../sdk/InternalRequestCallbackTests.java | 873 ++++++++++++++++++ 2 files changed, 1481 insertions(+) create mode 100644 sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java create mode 100644 sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java 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..617ce2e04 --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java @@ -0,0 +1,608 @@ +/* +Copyright (c) 2012, 2013, 2014 Countly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +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/InternalRequestCallbackTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java new file mode 100644 index 000000000..dc0067e88 --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java @@ -0,0 +1,873 @@ +/* +Copyright (c) 2012, 2013, 2014 Countly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +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.Map; +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; } + }; + } + + /** + * 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; } + }; + } +} From 49e75a8d5a520ded6669731e8b2aaaeb411e7b65 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 30 Jan 2026 15:23:34 +0300 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20remove=20=C5=9Ft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sdk/ConnectionQueueIntegrationTests.java | 21 ------------------- .../sdk/InternalRequestCallbackTests.java | 21 ------------------- 2 files changed, 42 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java index 617ce2e04..a6242d7a1 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java @@ -1,24 +1,3 @@ -/* -Copyright (c) 2012, 2013, 2014 Countly - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ package ly.count.android.sdk; import androidx.test.ext.junit.runners.AndroidJUnit4; diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java index dc0067e88..fb0f5e509 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java @@ -1,24 +1,3 @@ -/* -Copyright (c) 2012, 2013, 2014 Countly - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ package ly.count.android.sdk; import androidx.test.ext.junit.runners.AndroidJUnit4;