From 26b1929aac901f6e037fd15e008b3386af07907a Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 24 Nov 2025 16:33:43 +0300 Subject: [PATCH 01/33] feat: add check to prevent empty user details --- .../java/ly/count/sdk/java/internal/ModuleUserProfile.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java index f783a29d..b860caa6 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java @@ -259,7 +259,13 @@ protected void saveInternal() { if (internalConfig.sdk.location() != null) { internalConfig.sdk.module(ModuleLocation.class).saveLocationToParamsLegacy(generatedParams); } + L.d("[ModuleUserProfile] saveInternal, generated params [" + generatedParams + "]"); + if (generatedParams.length() <= 0) { + L.d("[ModuleUserProfile] saveInternal, nothing to save returning"); + return; + } + ModuleRequests.pushAsync(internalConfig, new Request(generatedParams)); clearInternal(); } From 0d63a433fd2092f48f01b30b846ffa0e31342e6e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 24 Nov 2025 16:34:06 +0300 Subject: [PATCH 02/33] feat: save events before user props sending --- .../main/java/ly/count/sdk/java/internal/ModuleEvents.java | 2 +- .../java/ly/count/sdk/java/internal/ModuleUserProfile.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java index 4cae3bb5..1db63aa0 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java @@ -139,7 +139,7 @@ private void addEventToQueue(EventImpl event) { checkEventQueueToSend(false); } - private void checkEventQueueToSend(boolean forceSend) { + void checkEventQueueToSend(boolean forceSend) { L.d("[ModuleEvents] queue size:[" + eventQueue.eqSize() + "] || forceSend: " + forceSend); if (forceSend || eventQueue.eqSize() >= internalConfig.getEventsBufferSize()) { addEventsToRequestQ(null); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java index b860caa6..e0c77ea6 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java @@ -266,6 +266,10 @@ protected void saveInternal() { return; } + if (internalConfig.sdk.events() != null) { + internalConfig.sdk.module(ModuleEvents.class).checkEventQueueToSend(true); + } + ModuleRequests.pushAsync(internalConfig, new Request(generatedParams)); clearInternal(); } From d304bbf8849023eec4caa8606c37e4e670e92dfc Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 24 Nov 2025 16:52:13 +0300 Subject: [PATCH 03/33] feat: user prop save on timer and without merge --- .../count/sdk/java/internal/ModuleUserProfile.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java index e0c77ea6..b6975ae5 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java @@ -298,6 +298,20 @@ public void stop(InternalConfig config, boolean clearData) { userProfileInterface = null; } + @Override + protected void onTimer() { + saveInternal(); + } + + @Override + public void deviceIdChanged(String oldDeviceId, boolean withMerge) { + super.deviceIdChanged(oldDeviceId, withMerge); + L.d("[ModuleUserProfile] deviceIdChanged: oldDeviceId = " + oldDeviceId + ", withMerge = " + withMerge); + if (!withMerge) { + saveInternal(); + } + } + public class UserProfile { /** From fe7add182c13b2cf832b5ec73bbb98d2e1c4c5d3 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 24 Nov 2025 18:21:29 +0300 Subject: [PATCH 04/33] feat: user prop save on event recording --- .../main/java/ly/count/sdk/java/internal/ModuleEvents.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java index 1db63aa0..f67ed04e 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java @@ -109,6 +109,10 @@ protected void recordEventInternal(String key, int count, Double sum, Double dur Utils.removeInvalidDataFromSegments(segmentation, L); + if (internalConfig.sdk.userProfile() != null) { + internalConfig.sdk.module(ModuleUserProfile.class).saveInternal(); + } + String eventId, pvid = null, cvid = null; if (Utils.isEmptyOrNull(eventIdOverride)) { L.d("[ModuleEvents] recordEventInternal, Generating new event id because it was null or empty"); From 7b3517b554c3727445047853f64728182adc5e1e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 24 Nov 2025 18:22:58 +0300 Subject: [PATCH 05/33] feat: user prop auto-save changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbdeb7c8..cf650a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ +## XX.XX.XX +* ! Minor breaking change ! User properties will now be automatically saved under the following conditions: + * When an event is recorded + * During an internal timer tick + * Upon flushing the event queue + ## 24.1.3 - * Extended minimum JDK support to 8. ## 24.1.2 From 9df4943209cdf72300ae52d3050f0adc75f5b283 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 24 Nov 2025 18:31:46 +0300 Subject: [PATCH 06/33] feat: user props on sessions --- .../java/ly/count/sdk/java/internal/SessionImpl.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java index 22a59e8d..7eee7c33 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java @@ -118,6 +118,9 @@ Future begin(Long now) { } this.consents = SDKCore.instance.consents; + if (config.sdk.userProfile() != null) { + config.sdk.module(ModuleUserProfile.class).saveInternal(); + } if (pushOnChange) { Storage.pushAsync(config, this); @@ -157,6 +160,9 @@ Future update(Long now) { } this.consents = SDKCore.instance.consents; + if (config.sdk.userProfile() != null) { + config.sdk.module(ModuleUserProfile.class).saveInternal(); + } Long duration = updateDuration(now); @@ -192,7 +198,10 @@ Future end(Long now, final Tasks.Callback callback, String did ended = now == null ? System.nanoTime() : now; this.consents = SDKCore.instance.consents; - + if (config.sdk.userProfile() != null) { + config.sdk.module(ModuleUserProfile.class).saveInternal(); + } + if (currentView != null) { currentView.stop(true); } else { From a52b71f777fad6523f91435f4622b9424ab5a66f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 24 Nov 2025 18:32:21 +0300 Subject: [PATCH 07/33] feat: user props on sessions changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbdeb7c8..d704f753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Updated user properties caching mechanism according to sessions. + ## 24.1.3 * Extended minimum JDK support to 8. From 16e0ef77f4f1d54663c9388db5bca3c60624d509 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 25 Nov 2025 10:06:02 +0300 Subject: [PATCH 08/33] fix: build gradle --- build.gradle | 42 +++++++++++++----------------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/build.gradle b/build.gradle index 8a55cf4f..31db9934 100644 --- a/build.gradle +++ b/build.gradle @@ -1,37 +1,21 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - repositories { - google() - mavenCentral() - jcenter() - maven { - url "https://maven.google.com" - } - } - dependencies { - classpath 'com.android.tools.build:gradle:8.1.4' - classpath 'com.github.dcendents:android-maven-plugin:1.2' - classpath 'com.google.gms:google-services:4.4.2' - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } + repositories { + google() + mavenCentral() + } } allprojects { - ext.CLY_VERSION = "24.1.3" - ext.POWERMOCK_VERSION = "1.7.4" + ext.CLY_VERSION = "24.1.3" + ext.POWERMOCK_VERSION = "1.7.4" - tasks.withType(Javadoc) { - options.addStringOption('Xdoclint:none', '-quiet') - } - repositories { - google() - jcenter() - //mavenLocal() - maven { - url "https://maven.google.com" // Google's Maven repository - } - } + tasks.withType(Javadoc) { + options.addStringOption('Xdoclint:none', '-quiet') + } + repositories { + google() + mavenCentral() + } } From c11abfbfd0897846280357b195b67a2b29808dea Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 25 Nov 2025 11:51:08 +0300 Subject: [PATCH 09/33] fix: build gradle publish plugin --- sdk-java/build.gradle | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/sdk-java/build.gradle b/sdk-java/build.gradle index 5c80b57c..48c0d0fa 100644 --- a/sdk-java/build.gradle +++ b/sdk-java/build.gradle @@ -8,7 +8,13 @@ buildscript { //mavenLocal() } dependencies { - classpath 'com.vanniktech:gradle-maven-publish-plugin:0.28.0' //for publishing + // Load publish plugin ONLY when publish task is requested + if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) { + classpath 'com.vanniktech:gradle-maven-publish-plugin:0.28.0' + println("[INFO] Maven Publish plugin ENABLED (publish task detected).") + } else { + println("[INFO] Maven Publish plugin SKIPPED (no publish task).") + } } } @@ -28,4 +34,7 @@ dependencies { //testImplementation 'com.squareup.okhttp3:mockwebserver:3.7.0' } -apply plugin: "com.vanniktech.maven.publish" +if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) { + apply plugin: "com.vanniktech.maven.publish" + println("[INFO] Maven Publish plugin APPLIED (publish task detected).") +} From 712135e0f4afe4e9320dd4d42b34b30ac4ce557f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 25 Nov 2025 11:53:44 +0300 Subject: [PATCH 10/33] fix: build gradle publish plugin --- sdk-java/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk-java/build.gradle b/sdk-java/build.gradle index 48c0d0fa..566d3ac2 100644 --- a/sdk-java/build.gradle +++ b/sdk-java/build.gradle @@ -11,9 +11,9 @@ buildscript { // Load publish plugin ONLY when publish task is requested if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) { classpath 'com.vanniktech:gradle-maven-publish-plugin:0.28.0' - println("[INFO] Maven Publish plugin ENABLED (publish task detected).") + println("[INFO] [Countly] Maven Publish plugin ENABLED (publish task detected).") } else { - println("[INFO] Maven Publish plugin SKIPPED (no publish task).") + println("[INFO] [Countly] Maven Publish plugin SKIPPED (no publish task).") } } } @@ -36,5 +36,5 @@ dependencies { if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) { apply plugin: "com.vanniktech.maven.publish" - println("[INFO] Maven Publish plugin APPLIED (publish task detected).") + println("[INFO] [Countly] Maven Publish plugin APPLIED (publish task detected).") } From 0ef0a906577fba4955bf4c9d2bfa2b6d5c00ce1d Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 25 Nov 2025 11:58:18 +0300 Subject: [PATCH 11/33] chore: add comment to it --- sdk-java/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk-java/build.gradle b/sdk-java/build.gradle index 566d3ac2..43f00ab2 100644 --- a/sdk-java/build.gradle +++ b/sdk-java/build.gradle @@ -10,6 +10,7 @@ buildscript { dependencies { // Load publish plugin ONLY when publish task is requested if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) { + // This requires minimum java 11 to work classpath 'com.vanniktech:gradle-maven-publish-plugin:0.28.0' println("[INFO] [Countly] Maven Publish plugin ENABLED (publish task detected).") } else { From a362a5f5527e19e627780caec0191f6061de3256 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 3 Dec 2025 11:16:00 +0300 Subject: [PATCH 12/33] feat: reverse config --- .../src/main/java/ly/count/sdk/java/Config.java | 16 ++++++++++++++++ .../count/sdk/java/internal/InternalConfig.java | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/Config.java b/sdk-java/src/main/java/ly/count/sdk/java/Config.java index c4f2372b..b982b450 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/Config.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/Config.java @@ -243,6 +243,7 @@ public class Config { protected String city = null; protected String country = null; protected boolean locationEnabled = true; + protected boolean autoSendUserProperties = true; // TODO: storage limits & configuration // protected int maxRequestsStored = 0; @@ -1480,4 +1481,19 @@ public String toString() { return "DID " + id + " ( " + strategy + ")"; } } + + // Disabling new Added features + + /** + * Disable automatic sending of user properties on + * - When an event is recorded + * - During an internal timer tick + * - Upon flushing the event queue + * + * @return {@code this} instance for method chaining + */ + public Config disableAutoSendUserProperties() { + this.autoSendUserProperties = false; + return this; + } } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java index 5c51185b..d446315d 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java @@ -32,6 +32,7 @@ public class InternalConfig extends Config { protected IdGenerator viewIdGenerator; protected IdGenerator eventIdGenerator; protected ViewIdProvider viewIdProvider; + /** * Shouldn't be used! */ @@ -211,4 +212,8 @@ String[] getLocationParams() { boolean isLocationDisabled() { return !locationEnabled; } + + boolean isAutoSendUserProperties() { + return autoSendUserProperties; + } } From 97e609481b366c1ebdf4982455449a65862a29c0 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 3 Dec 2025 12:11:16 +0300 Subject: [PATCH 13/33] feat: make it reversable --- .../main/java/ly/count/sdk/java/internal/ModuleEvents.java | 2 +- .../java/ly/count/sdk/java/internal/ModuleUserProfile.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java index f67ed04e..1e48a875 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleEvents.java @@ -109,7 +109,7 @@ protected void recordEventInternal(String key, int count, Double sum, Double dur Utils.removeInvalidDataFromSegments(segmentation, L); - if (internalConfig.sdk.userProfile() != null) { + if (internalConfig.isAutoSendUserProperties() && internalConfig.sdk.userProfile() != null) { internalConfig.sdk.module(ModuleUserProfile.class).saveInternal(); } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java index b6975ae5..f878ef84 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java @@ -266,7 +266,7 @@ protected void saveInternal() { return; } - if (internalConfig.sdk.events() != null) { + if (internalConfig.isAutoSendUserProperties() && internalConfig.sdk.events() != null) { internalConfig.sdk.module(ModuleEvents.class).checkEventQueueToSend(true); } @@ -307,7 +307,7 @@ protected void onTimer() { public void deviceIdChanged(String oldDeviceId, boolean withMerge) { super.deviceIdChanged(oldDeviceId, withMerge); L.d("[ModuleUserProfile] deviceIdChanged: oldDeviceId = " + oldDeviceId + ", withMerge = " + withMerge); - if (!withMerge) { + if (internalConfig.isAutoSendUserProperties() && !withMerge) { saveInternal(); } } From b91bdfa408648625a917b12d83806aa63629a1a1 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 3 Dec 2025 13:03:28 +0300 Subject: [PATCH 14/33] feat: make it reversable --- sdk-java/src/main/java/ly/count/sdk/java/Config.java | 11 +++++++++++ .../ly/count/sdk/java/internal/InternalConfig.java | 5 +++++ .../java/ly/count/sdk/java/internal/SessionImpl.java | 8 ++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/Config.java b/sdk-java/src/main/java/ly/count/sdk/java/Config.java index c4f2372b..953521ff 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/Config.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/Config.java @@ -243,6 +243,7 @@ public class Config { protected String city = null; protected String country = null; protected boolean locationEnabled = true; + protected boolean autoSendUserPropertiesOnSessions = true; // TODO: storage limits & configuration // protected int maxRequestsStored = 0; @@ -1480,4 +1481,14 @@ public String toString() { return "DID " + id + " ( " + strategy + ")"; } } + + /** + * Disable automatic sending of user properties on session begin, update and end + * + * @return {@code this} instance for method chaining + */ + public Config disableAutoSendUserPropertiesOnSessions() { + this.autoSendUserPropertiesOnSessions = false; + return this; + } } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java index 5c51185b..9aa60e9e 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/InternalConfig.java @@ -32,6 +32,7 @@ public class InternalConfig extends Config { protected IdGenerator viewIdGenerator; protected IdGenerator eventIdGenerator; protected ViewIdProvider viewIdProvider; + /** * Shouldn't be used! */ @@ -211,4 +212,8 @@ String[] getLocationParams() { boolean isLocationDisabled() { return !locationEnabled; } + + boolean isAutoSendUserPropertiesOnSessions() { + return autoSendUserPropertiesOnSessions; + } } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java index 7eee7c33..f966034f 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java @@ -118,7 +118,7 @@ Future begin(Long now) { } this.consents = SDKCore.instance.consents; - if (config.sdk.userProfile() != null) { + if (config.isAutoSendUserPropertiesOnSessions() && config.sdk.userProfile() != null) { config.sdk.module(ModuleUserProfile.class).saveInternal(); } @@ -160,7 +160,7 @@ Future update(Long now) { } this.consents = SDKCore.instance.consents; - if (config.sdk.userProfile() != null) { + if (config.isAutoSendUserPropertiesOnSessions() && config.sdk.userProfile() != null) { config.sdk.module(ModuleUserProfile.class).saveInternal(); } @@ -198,10 +198,10 @@ Future end(Long now, final Tasks.Callback callback, String did ended = now == null ? System.nanoTime() : now; this.consents = SDKCore.instance.consents; - if (config.sdk.userProfile() != null) { + if (config.isAutoSendUserPropertiesOnSessions() && config.sdk.userProfile() != null) { config.sdk.module(ModuleUserProfile.class).saveInternal(); } - + if (currentView != null) { currentView.stop(true); } else { From e85b36b826899d2478c4f0d12ba2968152a2fd6b Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 5 Dec 2025 09:54:14 +0300 Subject: [PATCH 15/33] feat: validate sessions trigger sending user props --- .../sdk/java/internal/SessionImplTests.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java index 859d50c3..d7f3d2f0 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java @@ -629,6 +629,49 @@ public void view_stopStartedAndNext() { ModuleViewsTests.validateView("next", 0.0, 2, 3, false, true, null, TestUtils.keysValues[1], TestUtils.keysValues[0]); } + /** + * Validates that when session calls are made, if any user properties are set, + * they are sent before sending that session call + * Validated with all session calls: begin, update, end + * + * @throws InterruptedException if thread is interrupted + */ + @Test + public void userPropsOnSessions() throws InterruptedException { + Countly.instance().init(TestUtils.getConfigSessions(Config.Feature.UserProfiles)); + Countly.instance().userProfile().setProperty("name", "John Doe"); + Countly.instance().userProfile().setProperty("custom_key", "custom_value"); + + Countly.session().begin(); + Map[] RQ = TestUtils.getCurrentRQ(); + UserEditorTests.validateUserDetailsRequestInRQ(TestUtils.map("user_details", TestUtils.json("name", "John Doe", "custom", TestUtils.map("custom_key", "custom_value"))), 0, 2); + Assert.assertEquals("1", RQ[1].get("begin_session")); + + Thread.sleep(2000); // wait for session to update + Countly.instance().userProfile().save(); + RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(2, RQ.length); // Validate that user properties are flushed + + Countly.instance().userProfile().setProperty("email", "john@doe.com"); + Countly.session().update(); + + RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(TestUtils.json("email", "john@doe.com"), RQ[2].get("user_details")); + Assert.assertEquals("2", RQ[3].get("session_duration")); + + Thread.sleep(2000); // wait for session to update + Countly.instance().userProfile().save(); + RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(4, RQ.length); // Validate that user properties are flushed with update call + + Countly.instance().userProfile().setProperty("done", "yes"); + Countly.session().end(); + + RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(TestUtils.json("custom", TestUtils.map("done", "yes")), RQ[4].get("user_details")); + Assert.assertEquals("1", RQ[5].get("end_session")); + } + private void validateNotEquals(int idOffset, BiFunction> setter) { Countly.instance().init(TestUtils.getConfigSessions()); long ts = TimeUtils.timestampMs(); From acdc4a7097162c9a5f2d832c99720a9d5ceb7219 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 5 Dec 2025 10:21:19 +0300 Subject: [PATCH 16/33] feat: tests for the user props auto save --- .../sdk/java/internal/ModuleEventsTests.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java index 66f7c7ed..70fe8fb1 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java @@ -442,6 +442,74 @@ public void timedEventFlow() throws InterruptedException { TestUtils.validateEventInEQ(eKeys[0], null, 2, 4.0, 2.0, 1, 2, TestUtils.keysValues[1], null, "", TestUtils.keysValues[0]); } + /** + * Recording events with user properties and with flushing events + * Validating that if a user property set before a recordEvent call it is sent before adding the event to EQ + * And also user properties packed after flushing events. + * + * @throws InterruptedException when sleep is interrupted + */ + @Test + public void eventsUserProps() throws InterruptedException { + init(TestUtils.getConfigEvents(4).setUpdateSessionTimerDelay(2)); + + Countly.instance().userProfile().setProperty("before_event", "value1"); + Countly.instance().events().recordEvent(eKeys[0]); + + Map[] RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(1, RQ.length); + Assert.assertEquals(TestUtils.json("custom", TestUtils.map("before_event", "value1")), RQ[0].get("user_details")); + TestUtils.validateEventInEQ(eKeys[0], null, 1, null, null, 0, 1, "_CLY_", null, "", null); + + Countly.instance().userProfile().setProperty("after_event", "value2"); + Thread.sleep(2500); // wait for the tick + RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(3, RQ.length); + Assert.assertTrue(RQ[1].containsKey("events")); + Assert.assertEquals(TestUtils.json("custom", TestUtils.map("after_event", "value2")), RQ[2].get("user_details")); + } + + /** + * Recording events with user properties and with flushing events + * Validating that if a user property save called, it flushes EQ before saving user properties + */ + @Test + public void eventsUserProps_propsSave() { + init(TestUtils.getConfigEvents(4)); + + Countly.instance().events().recordEvent(eKeys[0]); + + Map[] RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(0, RQ.length); + TestUtils.validateEventInEQ(eKeys[0], null, 1, null, null, 0, 1, "_CLY_", null, "", null); + + Countly.instance().userProfile().setProperty("after_event", "value2"); + Countly.instance().userProfile().save(); + + RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(2, RQ.length); + Assert.assertTrue(RQ[0].containsKey("events")); + Assert.assertEquals(TestUtils.json("custom", TestUtils.map("after_event", "value2")), RQ[1].get("user_details")); + } + + /** + * Validate that user properties are sent with timer tick if no events are recorded + */ + @Test + public void eventsUserProps_timer() throws InterruptedException { + init(TestUtils.getConfigEvents(4).setUpdateSessionTimerDelay(2)); + + Countly.instance().userProfile().setProperty("before_timer", "value1"); + + Map[] RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(0, RQ.length); + + Thread.sleep(2500); // wait for the tick + RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(1, RQ.length); + Assert.assertEquals(TestUtils.json("custom", TestUtils.map("before_timer", "value1")), RQ[0].get("user_details")); + } + private void validateTimedEventSize(int expectedQueueSize, int expectedTimedEventSize) { TestUtils.validateEQSize(expectedQueueSize, TestUtils.getCurrentEQ(), moduleEvents.eventQueue); Assert.assertEquals(expectedTimedEventSize, moduleEvents.timedEvents.size()); From 3a07203876ecbe0c08095ae02dc1c77daff9943e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray <57103426+arifBurakDemiray@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:53:39 +0300 Subject: [PATCH 17/33] Update build.gradle --- sdk-java/build.gradle | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/sdk-java/build.gradle b/sdk-java/build.gradle index 43f00ab2..193e8bcb 100644 --- a/sdk-java/build.gradle +++ b/sdk-java/build.gradle @@ -12,10 +12,7 @@ buildscript { if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) { // This requires minimum java 11 to work classpath 'com.vanniktech:gradle-maven-publish-plugin:0.28.0' - println("[INFO] [Countly] Maven Publish plugin ENABLED (publish task detected).") - } else { - println("[INFO] [Countly] Maven Publish plugin SKIPPED (no publish task).") - } + } } } From 2926827a47bc34aafc42ffa162e83cf3c74ef7c8 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray <57103426+arifBurakDemiray@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:54:20 +0300 Subject: [PATCH 18/33] Update build.gradle --- sdk-java/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk-java/build.gradle b/sdk-java/build.gradle index 193e8bcb..76790b55 100644 --- a/sdk-java/build.gradle +++ b/sdk-java/build.gradle @@ -34,5 +34,4 @@ dependencies { if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("publish") }) { apply plugin: "com.vanniktech.maven.publish" - println("[INFO] [Countly] Maven Publish plugin APPLIED (publish task detected).") } From 10001b1b8201ee51b08446876c2c697826e96f47 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray <57103426+arifBurakDemiray@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:56:53 +0300 Subject: [PATCH 19/33] Update CHANGELOG.md --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbdeb7c8..b7944c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ +## 24.1.4 + +* Cleaned up unused gradle dependencies from root build.gradle. + ## 24.1.3 * Extended minimum JDK support to 8. ## 24.1.2 + * !! Major Breaking Change !! Minimum JDK support is 19 for this minor. * Migrated from Sonatype OSSRH. @@ -214,3 +219,4 @@ * initial SDK release * MavenCentral rerelease + From 1924def7a411ed9ebda71bd395fff8839e423590 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 5 Dec 2025 13:02:03 +0300 Subject: [PATCH 20/33] feat: with disabled using --- .../sdk/java/internal/SessionImplTests.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java index d7f3d2f0..52e16e10 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java @@ -672,6 +672,38 @@ public void userPropsOnSessions() throws InterruptedException { Assert.assertEquals("1", RQ[5].get("end_session")); } + /** + * Validates that when session calls are made, if any user properties are set, + * they are not packed because auto-send is disabled + * + * @throws InterruptedException if thread is interrupted + */ + @Test + public void userPropsOnSessions_reversed() throws InterruptedException { + Countly.instance().init(TestUtils.getConfigSessions(Config.Feature.UserProfiles).disableAutoSendUserPropertiesOnSessions()); + Countly.instance().userProfile().setProperty("name", "John Doe"); + Countly.instance().userProfile().setProperty("custom_key", "custom_value"); + + Countly.session().begin(); + Map[] RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(1, RQ.length); + Assert.assertEquals("1", RQ[0].get("begin_session")); + + Thread.sleep(2000); // wait for session to update + Countly.session().update(); + RQ = TestUtils.getCurrentRQ(); + + Assert.assertEquals(2, RQ.length); + Assert.assertEquals("2", RQ[1].get("session_duration")); + + Thread.sleep(2000); + Countly.session().end(); + RQ = TestUtils.getCurrentRQ(); + + Assert.assertEquals(3, RQ.length); + Assert.assertEquals("1", RQ[2].get("end_session")); + } + private void validateNotEquals(int idOffset, BiFunction> setter) { Countly.instance().init(TestUtils.getConfigSessions()); long ts = TimeUtils.timestampMs(); From cf4fa094c8b60b53983c35474573da8efe530827 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 5 Dec 2025 13:11:34 +0300 Subject: [PATCH 21/33] fix: missing call --- .../sdk/java/internal/ModuleUserProfile.java | 4 +++- .../sdk/java/internal/ModuleEventsTests.java | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java index f878ef84..41aeb5da 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleUserProfile.java @@ -300,7 +300,9 @@ public void stop(InternalConfig config, boolean clearData) { @Override protected void onTimer() { - saveInternal(); + if (internalConfig.isAutoSendUserProperties()) { + saveInternal(); + } } @Override diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java index 70fe8fb1..606bfd9f 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java @@ -469,6 +469,30 @@ public void eventsUserProps() throws InterruptedException { Assert.assertEquals(TestUtils.json("custom", TestUtils.map("after_event", "value2")), RQ[2].get("user_details")); } + /** + * Recording events with user properties and with flushing events will not work because reversed + * + * @throws InterruptedException when sleep is interrupted + */ + @Test + public void eventsUserProps_reversed() throws InterruptedException { + init(TestUtils.getConfigEvents(4).setUpdateSessionTimerDelay(2).disableAutoSendUserProperties()); + + Countly.instance().userProfile().setProperty("before_event", "value1"); + Countly.instance().events().recordEvent(eKeys[0]); + + Map[] RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(0, RQ.length); + TestUtils.validateEventInEQ(eKeys[0], null, 1, null, null, 0, 1, "_CLY_", null, "", null); + + Countly.instance().userProfile().setProperty("after_event", "value2"); + Thread.sleep(2500); // wait for the tick + RQ = TestUtils.getCurrentRQ(); + + Assert.assertEquals(1, RQ.length); + Assert.assertTrue(RQ[0].containsKey("events")); + } + /** * Recording events with user properties and with flushing events * Validating that if a user property save called, it flushes EQ before saving user properties From cb538c067d95244446c7abf11207bbd7db3837db Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 5 Dec 2025 13:25:07 +0300 Subject: [PATCH 22/33] fix: gather all under one flag --- .../count/sdk/java/internal/SessionImpl.java | 6 +-- .../sdk/java/internal/ModuleEventsTests.java | 39 +++++++++++++++++++ .../sdk/java/internal/SessionImplTests.java | 2 +- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java index f966034f..295bbe7b 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/SessionImpl.java @@ -118,7 +118,7 @@ Future begin(Long now) { } this.consents = SDKCore.instance.consents; - if (config.isAutoSendUserPropertiesOnSessions() && config.sdk.userProfile() != null) { + if (config.isAutoSendUserProperties() && config.sdk.userProfile() != null) { config.sdk.module(ModuleUserProfile.class).saveInternal(); } @@ -160,7 +160,7 @@ Future update(Long now) { } this.consents = SDKCore.instance.consents; - if (config.isAutoSendUserPropertiesOnSessions() && config.sdk.userProfile() != null) { + if (config.isAutoSendUserProperties() && config.sdk.userProfile() != null) { config.sdk.module(ModuleUserProfile.class).saveInternal(); } @@ -198,7 +198,7 @@ Future end(Long now, final Tasks.Callback callback, String did ended = now == null ? System.nanoTime() : now; this.consents = SDKCore.instance.consents; - if (config.isAutoSendUserPropertiesOnSessions() && config.sdk.userProfile() != null) { + if (config.isAutoSendUserProperties() && config.sdk.userProfile() != null) { config.sdk.module(ModuleUserProfile.class).saveInternal(); } diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java index 606bfd9f..ac776174 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleEventsTests.java @@ -516,6 +516,28 @@ public void eventsUserProps_propsSave() { Assert.assertEquals(TestUtils.json("custom", TestUtils.map("after_event", "value2")), RQ[1].get("user_details")); } + /** + * Recording events with user properties and with flushing events + * Validating that if a user property save called, it does not flush EQ before saving user properties + */ + @Test + public void eventsUserProps_propsSave_reversed() { + init(TestUtils.getConfigEvents(4).disableAutoSendUserProperties()); + + Countly.instance().events().recordEvent(eKeys[0]); + + Map[] RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(0, RQ.length); + TestUtils.validateEventInEQ(eKeys[0], null, 1, null, null, 0, 1, "_CLY_", null, "", null); + + Countly.instance().userProfile().setProperty("after_event", "value2"); + Countly.instance().userProfile().save(); + + RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(1, RQ.length); + Assert.assertEquals(TestUtils.json("custom", TestUtils.map("after_event", "value2")), RQ[0].get("user_details")); + } + /** * Validate that user properties are sent with timer tick if no events are recorded */ @@ -534,6 +556,23 @@ public void eventsUserProps_timer() throws InterruptedException { Assert.assertEquals(TestUtils.json("custom", TestUtils.map("before_timer", "value1")), RQ[0].get("user_details")); } + /** + * Validate that user properties does not send with timer tick if no events are recorded + */ + @Test + public void eventsUserProps_timer_reversed() throws InterruptedException { + init(TestUtils.getConfigEvents(4).setUpdateSessionTimerDelay(2).disableAutoSendUserProperties()); + + Countly.instance().userProfile().setProperty("before_timer", "value1"); + + Map[] RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(0, RQ.length); + + Thread.sleep(2500); // wait for the tick + RQ = TestUtils.getCurrentRQ(); + Assert.assertEquals(0, RQ.length); + } + private void validateTimedEventSize(int expectedQueueSize, int expectedTimedEventSize) { TestUtils.validateEQSize(expectedQueueSize, TestUtils.getCurrentEQ(), moduleEvents.eventQueue); Assert.assertEquals(expectedTimedEventSize, moduleEvents.timedEvents.size()); diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java index 52e16e10..74b38403 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/SessionImplTests.java @@ -680,7 +680,7 @@ public void userPropsOnSessions() throws InterruptedException { */ @Test public void userPropsOnSessions_reversed() throws InterruptedException { - Countly.instance().init(TestUtils.getConfigSessions(Config.Feature.UserProfiles).disableAutoSendUserPropertiesOnSessions()); + Countly.instance().init(TestUtils.getConfigSessions(Config.Feature.UserProfiles).disableAutoSendUserProperties()); Countly.instance().userProfile().setProperty("name", "John Doe"); Countly.instance().userProfile().setProperty("custom_key", "custom_value"); From 993c1276cfd9f8041871cd2a295cd804da70a35b Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 5 Dec 2025 13:28:51 +0300 Subject: [PATCH 23/33] feat: 24.1.4 --- build.gradle | 2 +- gradle.properties | 2 +- sdk-java/src/main/java/ly/count/sdk/java/Config.java | 2 +- .../src/test/java/ly/count/sdk/java/internal/TestUtils.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 31db9934..c2566abe 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { } allprojects { - ext.CLY_VERSION = "24.1.3" + ext.CLY_VERSION = "24.1.4" ext.POWERMOCK_VERSION = "1.7.4" tasks.withType(Javadoc) { diff --git a/gradle.properties b/gradle.properties index 93f08405..44c13b92 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,7 +18,7 @@ # org.gradle.parallel=true # RELEASE FIELD SECTION -VERSION_NAME=24.1.3 +VERSION_NAME=24.1.4 GROUP=ly.count.sdk POM_URL=https://github.com/Countly/countly-sdk-java diff --git a/sdk-java/src/main/java/ly/count/sdk/java/Config.java b/sdk-java/src/main/java/ly/count/sdk/java/Config.java index 953521ff..acf6e035 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/Config.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/Config.java @@ -68,7 +68,7 @@ public class Config { /** * Countly SDK version to be sent in HTTP requests */ - protected String sdkVersion = "24.1.3"; + protected String sdkVersion = "24.1.4"; /** * Countly SDK version to be sent in HTTP requests diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java index c959a5b9..55301e82 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java @@ -39,7 +39,7 @@ public class TestUtils { static String SERVER_APP_KEY = "COUNTLY_APP_KEY"; static String DEVICE_ID = "some_random_test_device_id"; static String SDK_NAME = "java-native"; - static String SDK_VERSION = "24.1.3"; + static String SDK_VERSION = "24.1.4"; static String APPLICATION_VERSION = "1.0"; public static final String[] eKeys = new String[] { "eventKey1", "eventKey2", "eventKey3", "eventKey4", "eventKey5", "eventKey6", "eventKey7" }; From 0365369f349025208551024922be4489e5ef2fd1 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 15 Apr 2026 22:39:58 +0300 Subject: [PATCH 24/33] fix: npe when clutter crash --- .../ly/count/sdk/java/internal/SDKCore.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java index b912c567..1a273436 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKCore.java @@ -459,8 +459,6 @@ public void init(final InternalConfig givenConfig) { modules.remove(feature); } - recover(config); - if (config.isDefaultNetworking()) { networking = new DefaultNetworking(); @@ -513,17 +511,23 @@ public Integer remaningRequests() { } } + recover(config); + user = new UserImpl(config); initFinished(config); } - private void initFinished(final InternalConfig config) { - modules.forEach((feature, module) -> module.initFinished(config)); - if (config.isDefaultNetworking()) { + private void checkNetworking(InternalConfig config) { + if (networking != null) { networking.check(config); } } + private void initFinished(final InternalConfig config) { + modules.forEach((feature, module) -> module.initFinished(config)); + checkNetworking(config); + } + public UserImpl user() { return user; } @@ -650,13 +654,13 @@ protected void recover(InternalConfig config) { public void onSignal(InternalConfig config, int id) { if (id == Signal.DID.getIndex()) { - networking.check(config); + checkNetworking(config); } } public void onSignal(InternalConfig config, int id, String param) { if (id == Signal.Ping.getIndex()) { - networking.check(config); + checkNetworking(config); } else if (id == Signal.Crash.getIndex()) { processCrash(config, Long.parseLong(param)); } @@ -679,7 +683,7 @@ private boolean processCrash(InternalConfig config, Long id) { if (Storage.push(config, request)) { L.i("[SDKCore] Added request " + request.storageId() + " instead of crash " + crash.storageId()); - networking.check(config); + checkNetworking(config); Boolean success = Storage.remove(config, crash); return (success != null) && success; } else { From 222bdc775d0f95ba1e9fbe436ad064a6cd01e8db Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 15 Apr 2026 22:40:10 +0300 Subject: [PATCH 25/33] fix: npe when clutter crash: changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6573fca2..c01041f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Fixed a bug where a NullPointerException in SDKCore.recover() would permanently block SDK initialization when a crash file from a previous session existed on disk. + ## 24.1.4 * ! Minor breaking change ! User properties will now be automatically saved under the following conditions: * When an event is recorded From d31a4c121590f86b459d506394145d269566e10a Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 15 Apr 2026 22:40:30 +0300 Subject: [PATCH 26/33] fix: issue reproduce app --- .../ly/count/java/demo/ReproduceIssue263.java | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 app-java/src/main/java/ly/count/java/demo/ReproduceIssue263.java diff --git a/app-java/src/main/java/ly/count/java/demo/ReproduceIssue263.java b/app-java/src/main/java/ly/count/java/demo/ReproduceIssue263.java new file mode 100644 index 00000000..bd9a89be --- /dev/null +++ b/app-java/src/main/java/ly/count/java/demo/ReproduceIssue263.java @@ -0,0 +1,118 @@ +package ly.count.java.demo; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import ly.count.sdk.java.Config; +import ly.count.sdk.java.Countly; +import org.json.JSONObject; + +/** + * Reproducer for GitHub issue #263: + * "NullPointerException in SDKCore.recover() permanently blocks SDK initialization + * when a crash file exists" + * + * This simulates the scenario where: + * 1. A previous app run crashed and left a [CLY]_crash_* file on disk + * 2. The app restarts and calls Countly.init() + * 3. SDKCore.recover() finds the crash file and tries to process it + * + * WITHOUT the fix: NPE at networking.check(config) because networking is null + * WITH the fix: SDK initializes normally, crash is queued as a request + */ +public class ReproduceIssue263 { + + public static void main(String[] args) { + String[] sdkStorageRootPath = { System.getProperty("user.home"), "__COUNTLY", "java_issue_263" }; + File sdkStorageRootDirectory = new File(String.join(File.separator, sdkStorageRootPath)); + + if (!(sdkStorageRootDirectory.exists() && sdkStorageRootDirectory.isDirectory())) { + if (!sdkStorageRootDirectory.mkdirs()) { + System.out.println("[FAIL] Directory creation failed"); + return; + } + } + + // Step 1: Plant a fake crash file as if a previous run crashed + long crashTimestamp = System.currentTimeMillis() - 2000; + File crashFile = new File(sdkStorageRootDirectory, "[CLY]_crash_" + crashTimestamp); + + JSONObject crashData = new JSONObject(); + crashData.put("_error", "java.lang.RuntimeException: simulated crash from previous session\n" + + "\tat com.example.App.doSomething(App.java:42)\n" + + "\tat com.example.App.main(App.java:10)"); + crashData.put("_nonfatal", false); + crashData.put("_os", "Java"); + crashData.put("_os_version", System.getProperty("java.version")); + crashData.put("_device", "ReproducerDevice"); + crashData.put("_resolution", "1920x1080"); + + try (BufferedWriter writer = Files.newBufferedWriter(crashFile.toPath())) { + writer.write(crashData.toString()); + } catch (IOException e) { + System.out.println("[FAIL] Could not write crash file: " + e.getMessage()); + return; + } + + System.out.println("[INFO] Planted crash file: " + crashFile.getAbsolutePath()); + System.out.println("[INFO] Crash file exists: " + crashFile.exists()); + System.out.println(); + + // Step 2: Initialize SDK — this is where the NPE would occur + System.out.println("[TEST] Initializing SDK with crash file present..."); + System.out.println("[TEST] If issue #263 is NOT fixed, you will see a NullPointerException below."); + System.out.println(); + + try { + Config config = new Config("https://test.server.ly", "TEST_APP_KEY", sdkStorageRootDirectory) + .setLoggingLevel(Config.LoggingLevel.DEBUG) + .enableFeatures(Config.Feature.CrashReporting, Config.Feature.Events, Config.Feature.Sessions); + + Countly.instance().init(config); + + System.out.println(); + System.out.println("[PASS] SDK initialized successfully!"); + System.out.println("[INFO] Crash file still exists: " + crashFile.exists()); + + if (!crashFile.exists()) { + System.out.println("[PASS] Crash file was processed and removed during recovery."); + } else { + System.out.println("[WARN] Crash file was NOT removed — recovery may have partially failed."); + } + + // Check for request files (crash should be converted to a request) + File[] requestFiles = sdkStorageRootDirectory.listFiles( + (dir, name) -> name.startsWith("[CLY]_request_")); + if (requestFiles != null && requestFiles.length > 0) { + System.out.println("[PASS] Found " + requestFiles.length + " request file(s) — crash was queued for sending."); + } + + // Clean shutdown + Countly.instance().halt(); + System.out.println("[INFO] SDK stopped cleanly."); + + } catch (NullPointerException e) { + System.out.println(); + System.out.println("[FAIL] *** NullPointerException — Issue #263 is NOT fixed! ***"); + System.out.println("[FAIL] " + e.getMessage()); + e.printStackTrace(); + } catch (Exception e) { + System.out.println(); + System.out.println("[FAIL] Unexpected exception: " + e.getClass().getSimpleName() + ": " + e.getMessage()); + e.printStackTrace(); + } finally { + // Cleanup: remove test files + System.out.println(); + System.out.println("[INFO] Cleaning up test directory..."); + File[] files = sdkStorageRootDirectory.listFiles(); + if (files != null) { + for (File f : files) { + f.delete(); + } + } + sdkStorageRootDirectory.delete(); + System.out.println("[INFO] Done."); + } + } +} From 904df356f72c1d54fc5ff67bbe99185608d38786 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 15 Apr 2026 22:41:07 +0300 Subject: [PATCH 27/33] feat: tests --- .../internal/ScenarioInitRecoveryTests.java | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java new file mode 100644 index 00000000..da006423 --- /dev/null +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java @@ -0,0 +1,359 @@ +package ly.count.sdk.java.internal; + +import java.io.File; +import ly.count.sdk.java.Config; +import ly.count.sdk.java.Countly; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Tests for SDK initialization ordering and recovery scenarios. + * + * These tests verify that the SDK initializes correctly when leftover + * crash files or session files exist from a previous run. This covers + * the fix for GitHub issue #263 (NPE in SDKCore.recover() when a crash + * file exists) and related initialization ordering concerns. + */ +@RunWith(JUnit4.class) +public class ScenarioInitRecoveryTests { + + /** Time to wait for async storage/networking operations to settle */ + private static final int ASYNC_SETTLE_MS = 200; + + @Before + public void beforeTest() { + TestUtils.createCleanTestState(); + } + + @After + public void stop() { + Countly.instance().halt(); + } + + // ── Test helpers ──────────────────────────────────────────────────── + + private JSONObject createCrashData(String error, boolean nonfatal) { + JSONObject data = new JSONObject(); + data.put("_error", error); + data.put("_nonfatal", nonfatal); + return data; + } + + private long plantCrashFile(JSONObject crashData) { + long crashId = TimeUtils.uniqueTimestampMs(); + TestUtils.writeToFile("crash_" + crashId, crashData.toString()); + return crashId; + } + + private long plantCrashFileAndInit(Config config) throws InterruptedException { + long crashId = plantCrashFile(createCrashData("java.lang.RuntimeException: test", false)); + Countly.instance().init(config); + Thread.sleep(ASYNC_SETTLE_MS); + return crashId; + } + + private void assertCrashFileRemoved(long crashId) { + File crashFile = new File(TestUtils.getTestSDirectory(), "[CLY]_crash_" + crashId); + Assert.assertFalse("Crash file " + crashId + " should be removed after recovery", crashFile.exists()); + } + + private void assertCrashFileExists(long crashId) { + File crashFile = new File(TestUtils.getTestSDirectory(), "[CLY]_crash_" + crashId); + Assert.assertTrue("Crash file " + crashId + " should still exist", crashFile.exists()); + } + + private void assertMinRequestFiles(int expectedMin) { + File testDir = TestUtils.getTestSDirectory(); + File[] requestFiles = testDir.listFiles((dir, name) -> name.startsWith("[CLY]_request_")); + Assert.assertNotNull("Request file listing should not be null", requestFiles); + Assert.assertTrue("Expected at least " + expectedMin + " request file(s), found " + requestFiles.length, + requestFiles.length >= expectedMin); + } + + private void withNullNetworking(Runnable action) { + Networking saved = SDKCore.instance.networking; + SDKCore.instance.networking = null; + try { + action.run(); + } finally { + SDKCore.instance.networking = saved; + } + } + + // ── Crash recovery tests ──────────────────────────────────────────── + + /** + * "init_withExistingCrashFile" + * Primary regression test for issue #263. + * Crash file should be converted into a request and removed from disk. + */ + @Test + public void init_withExistingCrashFile() throws InterruptedException { + JSONObject crashData = createCrashData( + "java.lang.RuntimeException: test crash\n\tat com.test.App.main(App.java:10)", false); + crashData.put("_os", "Java"); + crashData.put("_os_version", "17"); + crashData.put("_device", "TestDevice"); + + long crashId = plantCrashFile(crashData); + assertCrashFileExists(crashId); + + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting)); + Thread.sleep(ASYNC_SETTLE_MS); + + assertCrashFileRemoved(crashId); + assertMinRequestFiles(1); + } + + /** + * "init_withExistingCrashFile_noFeatureEnabled" + * Crash file recovery happens at SDKCore level regardless of feature flags. + * The crash file should still be processed and removed. + */ + @Test + public void init_withExistingCrashFile_noFeatureEnabled() throws InterruptedException { + long crashId = plantCrashFile(createCrashData("java.lang.NullPointerException: test", true)); + + Countly.instance().init(TestUtils.getBaseConfig()); + Thread.sleep(ASYNC_SETTLE_MS); + + Assert.assertTrue("SDK should be initialized", Countly.isInitialized()); + assertCrashFileRemoved(crashId); + assertMinRequestFiles(1); + } + + /** + * "init_withMultipleCrashFiles" + * All crash files should be converted to requests and removed. + */ + @Test + public void init_withMultipleCrashFiles() throws InterruptedException { + long[] crashIds = new long[3]; + for (int i = 0; i < 3; i++) { + crashIds[i] = plantCrashFile(createCrashData("Exception #" + i, i % 2 == 0)); + } + + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting)); + Thread.sleep(ASYNC_SETTLE_MS); + + for (int i = 0; i < 3; i++) { + assertCrashFileRemoved(crashIds[i]); + } + assertMinRequestFiles(3); + } + + // ── Session recovery tests ────────────────────────────────────────── + + /** + * "init_withExistingSessionFile" + * Session recovery calls session.end() -> onSignal(Ping) -> networking.check(). + * Uses stop() (not halt()) to preserve session files on disk. + */ + @Test + public void init_withExistingSessionFile() throws InterruptedException { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Sessions)); + Countly.instance().session().begin(); + Thread.sleep(ASYNC_SETTLE_MS); + + // stop() preserves files; halt() would delete them + Countly.instance().stop(); + + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.Sessions)); + Thread.sleep(ASYNC_SETTLE_MS); + + Assert.assertTrue("SDK should be initialized after session recovery", Countly.isInitialized()); + } + + /** + * "init_withCrashAndSessionFiles" + * Both crash files and session files present simultaneously. + * Verifies recover() processes both crash loop and session loop without interference. + */ + @Test + public void init_withCrashAndSessionFiles() throws InterruptedException { + // First init: create a session file + Countly.instance().init(TestUtils.getBaseConfig() + .enableFeatures(Config.Feature.Sessions, Config.Feature.CrashReporting)); + Countly.instance().session().begin(); + Thread.sleep(ASYNC_SETTLE_MS); + Countly.instance().stop(); + + // Plant a crash file on top of the leftover session file + long crashId = plantCrashFile(createCrashData("java.lang.RuntimeException: dual recovery", false)); + + // Re-init should recover both + Countly.instance().init(TestUtils.getBaseConfig() + .enableFeatures(Config.Feature.Sessions, Config.Feature.CrashReporting)); + Thread.sleep(ASYNC_SETTLE_MS); + + Assert.assertTrue("SDK should initialize with both crash and session files", Countly.isInitialized()); + assertCrashFileRemoved(crashId); + } + + // ── Corrupt/empty file resilience ─────────────────────────────────── + + /** + * "init_withCorruptCrashFile" + * Corrupt crash file should not block initialization. + * processCrash returns false when Storage.read fails, so the file is NOT removed + * (the remove only happens after successful push). The SDK should still init. + */ + @Test + public void init_withCorruptCrashFile() throws InterruptedException { + long crashId = System.currentTimeMillis() - 1000; + TestUtils.writeToFile("crash_" + crashId, "this is not valid json {{{"); + + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting)); + Thread.sleep(ASYNC_SETTLE_MS); + + Assert.assertTrue("SDK should be initialized even with corrupt crash file", Countly.isInitialized()); + // Corrupt file is NOT cleaned up by processCrash (it returns false at the null check) + // This is expected — the file will be retried on next init + } + + /** + * "init_emptyCrashFile" + * Empty crash file should not cause initialization failure. + */ + @Test + public void init_emptyCrashFile() throws InterruptedException { + long crashId = System.currentTimeMillis() - 1000; + TestUtils.writeToFile("crash_" + crashId, ""); + + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting)); + Thread.sleep(ASYNC_SETTLE_MS); + + Assert.assertTrue("SDK should initialize with empty crash file", Countly.isInitialized()); + } + + // ── Feature combination tests ─────────────────────────────────────── + + /** + * "init_withCrashFile_remoteConfigEnabled" + * Exercises both crash recovery and ModuleRemoteConfig.initFinished() + * needing networking to be ready. + */ + @Test + public void init_withCrashFile_remoteConfigEnabled() throws InterruptedException { + long crashId = plantCrashFileAndInit(TestUtils.getBaseConfig() + .enableFeatures(Config.Feature.CrashReporting, Config.Feature.RemoteConfig)); + + Assert.assertTrue("SDK should initialize with crash file + remote config", Countly.isInitialized()); + assertCrashFileRemoved(crashId); + assertMinRequestFiles(1); + } + + /** + * "init_withCrashFile_locationEnabled" + * ModuleLocation.initFinished() -> sendLocation() -> onSignal(Ping) -> networking.check(). + */ + @Test + public void init_withCrashFile_locationEnabled() throws InterruptedException { + long crashId = plantCrashFileAndInit(TestUtils.getBaseConfig() + .enableFeatures(Config.Feature.CrashReporting, Config.Feature.Location) + .setLocation("US", "New York", "40.7128,-74.0060", null)); + + Assert.assertTrue("SDK should initialize with crash file + location", Countly.isInitialized()); + assertCrashFileRemoved(crashId); + assertMinRequestFiles(1); + } + + /** + * "init_withCrashFile_allFeaturesEnabled" + * Stress test: all module initFinished() paths exercised simultaneously. + */ + @Test + public void init_withCrashFile_allFeaturesEnabled() throws InterruptedException { + long crashId = plantCrashFileAndInit(TestUtils.getBaseConfig() + .enableFeatures( + Config.Feature.CrashReporting, + Config.Feature.Events, + Config.Feature.Sessions, + Config.Feature.Views, + Config.Feature.Location, + Config.Feature.RemoteConfig, + Config.Feature.Feedback + )); + + Assert.assertTrue("SDK should initialize with crash file + all features", Countly.isInitialized()); + assertCrashFileRemoved(crashId); + assertMinRequestFiles(1); + } + + // ── Networking null-safety tests ──────────────────────────────────── + + /** + * "init_networkingNullSafety_onSignalDID" + * Validates the null guard in SDKCore.onSignal(config, id) for DID signal. + */ + @Test + public void init_networkingNullSafety_onSignalDID() { + Countly.instance().init(TestUtils.getBaseConfig()); + + withNullNetworking(() -> + SDKCore.instance.onSignal(SDKCore.instance.config, SDKCore.Signal.DID.getIndex())); + + Assert.assertTrue("SDK should remain functional", Countly.isInitialized()); + } + + /** + * "init_networkingNullSafety_onSignalPing" + * Validates the null guard in SDKCore.onSignal(config, id, param) for Ping signal. + */ + @Test + public void init_networkingNullSafety_onSignalPing() { + Countly.instance().init(TestUtils.getBaseConfig()); + + withNullNetworking(() -> + SDKCore.instance.onSignal(SDKCore.instance.config, SDKCore.Signal.Ping.getIndex(), null)); + + Assert.assertTrue("SDK should remain functional", Countly.isInitialized()); + } + + /** + * "init_networkingNullSafety_processCrash" + * Plants a crash file AFTER init, nulls networking, then triggers the crash signal. + * This exercises the null guard inside processCrash() directly. + */ + @Test + public void init_networkingNullSafety_processCrash() throws InterruptedException { + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting)); + Thread.sleep(ASYNC_SETTLE_MS); + + // Plant crash after init so it hasn't been processed yet + long crashId = plantCrashFile(createCrashData("java.lang.RuntimeException: post-init crash", false)); + + withNullNetworking(() -> + SDKCore.instance.onSignal(SDKCore.instance.config, SDKCore.Signal.Crash.getIndex(), String.valueOf(crashId))); + + Assert.assertTrue("SDK should remain functional after processCrash with null networking", Countly.isInitialized()); + } + + // ── Regression tests ──────────────────────────────────────────────── + + /** + * "init_repeatInit_withCrashFile" + * Verifies crash file is removed on first init so it doesn't permanently block startup. + * This was the user-visible symptom of issue #263. + */ + @Test + public void init_repeatInit_withCrashFile() throws InterruptedException { + long crashId = plantCrashFileAndInit( + TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting)); + + Assert.assertTrue("First init should succeed", Countly.isInitialized()); + assertCrashFileRemoved(crashId); + + Countly.instance().halt(); + + // Second init — no crash file to recover + Countly.instance().init(TestUtils.getBaseConfig().enableFeatures(Config.Feature.CrashReporting)); + Thread.sleep(ASYNC_SETTLE_MS); + Assert.assertTrue("Second init should succeed without leftover crash files", Countly.isInitialized()); + } +} From f885609e7bc00ad4551d4eb1dc09e0f8bd338106 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 15 Apr 2026 22:41:56 +0300 Subject: [PATCH 28/33] feat: upgrade mockito --- sdk-java/build.gradle | 4 +- .../ly/count/sdk/java/internal/LogTests.java | 2 - .../count/sdk/java/internal/RequestTests.java | 39 +++++++++++-------- .../count/sdk/java/internal/TasksTests.java | 20 ++++++---- 4 files changed, 36 insertions(+), 29 deletions(-) diff --git a/sdk-java/build.gradle b/sdk-java/build.gradle index 76790b55..a7db0009 100644 --- a/sdk-java/build.gradle +++ b/sdk-java/build.gradle @@ -26,9 +26,7 @@ dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' testImplementation 'junit:junit:4.13.1' - testImplementation 'org.mockito:mockito-core:2.8.9' - testImplementation "org.powermock:powermock-core:${POWERMOCK_VERSION}" - testImplementation "org.powermock:powermock-module-junit4:${POWERMOCK_VERSION}" + testImplementation 'org.mockito:mockito-core:4.11.0' //testImplementation 'com.squareup.okhttp3:mockwebserver:3.7.0' } diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/LogTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/LogTests.java index fd1f0b3d..9d7c95a4 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/LogTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/LogTests.java @@ -6,8 +6,6 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; -import org.powermock.reflect.Whitebox; - import ly.count.sdk.java.Config; import static ly.count.sdk.java.Config.LoggingLevel.DEBUG; diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/RequestTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/RequestTests.java index 4a7c8b26..5535dd27 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/RequestTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/RequestTests.java @@ -1,12 +1,12 @@ package ly.count.sdk.java.internal; +import java.lang.reflect.Field; import java.net.URL; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; -import org.powermock.reflect.Whitebox; @RunWith(JUnit4.class) public class RequestTests { @@ -18,31 +18,36 @@ public void setupEveryTest() throws Exception { url = new URL(urlString); } + private static String getEOR() throws Exception { + Field field = Request.class.getDeclaredField("EOR"); + field.setAccessible(true); + return (String) field.get(null); + } + @Test - public void request_constructorString() throws Exception { + public void request_constructorString() { String paramVals = "a=1&b=2"; Params params = new Params(paramVals); - Request request = Whitebox.invokeConstructor(Request.class, paramVals); + Request request = new Request(paramVals); Params requestParams = request.params; Assert.assertEquals(params.toString(), requestParams.toString()); } @Test - public void request_constructorObjectsNull() throws Exception { + public void request_constructorObjectsNull() { String[] paramsVals = new String[] { "asd", "123" }; - Object[] vals = new Object[] { new Object[] { paramsVals[0], paramsVals[1] } }; - Request request = Whitebox.invokeConstructor(Request.class, vals); + Request request = new Request((Object[]) new Object[] { paramsVals[0], paramsVals[1] }); Assert.assertEquals(paramsVals[0] + "=" + paramsVals[1], request.params.toString()); } @Test - public void request_constructorObjects() throws Exception { + public void request_constructorObjects() { String[] paramsParts = new String[] { "abc", "123", "qwe", "456" }; String paramVals = paramsParts[0] + "=" + paramsParts[1] + "&" + paramsParts[2] + "=" + paramsParts[3]; Params params = new Params(paramVals); - Request request = Whitebox.invokeConstructor(Request.class, paramsParts[0], paramsParts[1], paramsParts[2], paramsParts[3]); + Request request = new Request(paramsParts[0], paramsParts[1], paramsParts[2], paramsParts[3]); Params requestParams = request.params; Assert.assertEquals(params.toString(), requestParams.toString()); } @@ -61,17 +66,17 @@ public void request_build() { @Test public void request_serialize() throws Exception { String paramVals = "a=1&b=2"; - Request request = Whitebox.invokeConstructor(Request.class, paramVals); + Request request = new Request(paramVals); - String manualSerialization = paramVals + Whitebox.getInternalState(Request.class, "EOR"); + String manualSerialization = paramVals + getEOR(); String serializationRes = new String(request.store(null)); Assert.assertEquals(manualSerialization, serializationRes); } @Test - public void request_loadSimple() throws Exception { + public void request_loadSimple() { String paramVals = "a=1&b=2"; - Request request = Whitebox.invokeConstructor(Request.class, paramVals); + Request request = new Request(paramVals); byte[] serializationRes = request.store(null); Request requestNew = new Request(); @@ -92,13 +97,13 @@ public void request_loadNull() { } @Test - public void isGettable_ParamsEmptyUnderLimit() throws Exception { - Request request = Whitebox.invokeConstructor(Request.class, ""); + public void isGettable_ParamsEmptyUnderLimit() { + Request request = new Request(""); Assert.assertTrue(request.isGettable(url, 0)); } @Test - public void isGettable_ParamsFilledAboveLimitLarge() throws Exception { + public void isGettable_ParamsFilledAboveLimitLarge() { StringBuilder sbParams = new StringBuilder(); for (int a = 0; a < 1000; a++) { @@ -109,8 +114,8 @@ public void isGettable_ParamsFilledAboveLimitLarge() throws Exception { sbParams.append('=').append(a); } - Request request = Whitebox.invokeConstructor(Request.class, sbParams.toString()); + Request request = new Request(sbParams.toString()); Assert.assertFalse(request.isGettable(url, 0)); } -} \ No newline at end of file +} diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksTests.java index bcbce23f..6cbf8825 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksTests.java @@ -1,5 +1,6 @@ package ly.count.sdk.java.internal; +import java.lang.reflect.Field; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import org.junit.After; @@ -8,7 +9,6 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; -import org.powermock.reflect.Whitebox; @RunWith(JUnit4.class) public class TasksTests { @@ -24,14 +24,20 @@ public void tearDown() throws Exception { tasks.shutdown(); } + private static Object getField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(target); + } + @Test - public void testSetup() { - Assert.assertNotNull(Whitebox.getInternalState(tasks, "executor")); - Assert.assertNotNull(Whitebox.getInternalState(tasks, "pending")); + public void testSetup() throws Exception { + Assert.assertNotNull(getField(tasks, "executor")); + Assert.assertNotNull(getField(tasks, "pending")); } @Test - public void testShutdown() { + public void testShutdown() throws Exception { Tasks other = new Tasks("test", null); other.run(new Tasks.Task(0L) { @Override @@ -43,8 +49,8 @@ public Object call() throws Exception { long now = System.nanoTime(); other.shutdown(); long timeToShutdown = TimeUtils.nsToMs(System.nanoTime() - now); - Assert.assertTrue(Whitebox.getInternalState(other, "executor").isShutdown()); - Assert.assertTrue(Whitebox.getInternalState(other, "executor").isTerminated()); + Assert.assertTrue(((ExecutorService) getField(other, "executor")).isShutdown()); + Assert.assertTrue(((ExecutorService) getField(other, "executor")).isTerminated()); //Assert.assertTrue(timeToShutdown > 100);//todo, this line fails when trying to publish (AK, 12.12.18) } From 06d1f934e9bc0bf5f93bd71d429488f9c5dd8e13 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 15 Apr 2026 22:44:08 +0300 Subject: [PATCH 29/33] feat: changelog for deadlock bug --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6573fca2..3c2688d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Fixed a bug where a non-JSON server response would cause a permanent networking deadlock, preventing all subsequent requests from being sent. + ## 24.1.4 * ! Minor breaking change ! User properties will now be automatically saved under the following conditions: * When an event is recorded From 0485be854a0d48486192820e40e7c7c8d66be18e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 15 Apr 2026 22:46:18 +0300 Subject: [PATCH 30/33] feat: tests about it --- .../ScenarioNetworkDeadlockTests.java | 202 ++++++++++++ .../internal/TasksExceptionRecoveryTests.java | 290 ++++++++++++++++++ .../sdk/java/internal/TransportTests.java | 138 +++++++++ 3 files changed, 630 insertions(+) create mode 100644 sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioNetworkDeadlockTests.java create mode 100644 sdk-java/src/test/java/ly/count/sdk/java/internal/TasksExceptionRecoveryTests.java create mode 100644 sdk-java/src/test/java/ly/count/sdk/java/internal/TransportTests.java diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioNetworkDeadlockTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioNetworkDeadlockTests.java new file mode 100644 index 00000000..5a2cd31a --- /dev/null +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioNetworkDeadlockTests.java @@ -0,0 +1,202 @@ +package ly.count.sdk.java.internal; + +import com.sun.net.httpserver.HttpServer; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicInteger; +import ly.count.sdk.java.Config; +import ly.count.sdk.java.Countly; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * End-to-end scenario tests for GitHub issue #264: + * "Non-JSON Server Response Causes Permanent Networking Deadlock" + * + * These tests spin up a local HTTP server that returns non-JSON responses + * (simulating 502/503 error pages), then verify the SDK recovers gracefully + * and resumes sending requests when the server comes back. + */ +@RunWith(JUnit4.class) +public class ScenarioNetworkDeadlockTests { + + private HttpServer server; + private int port; + + @Before + public void setUp() throws Exception { + TestUtils.createCleanTestState(); + } + + @After + public void tearDown() { + Countly.instance().halt(); + if (server != null) { + server.stop(0); + } + } + + /** + * Start a local HTTP server with a custom handler. + * Returns the port number. + */ + private void startServer(ServerBehavior behavior) throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + port = server.getAddress().getPort(); + server.createContext("/", exchange -> { + Response resp = behavior.respond(); + exchange.sendResponseHeaders(resp.code, resp.body.length()); + OutputStream os = exchange.getResponseBody(); + os.write(resp.body.getBytes()); + os.close(); + }); + server.start(); + } + + private Config configForLocalServer() { + return new Config("http://localhost:" + port, TestUtils.SERVER_APP_KEY, TestUtils.getTestSDirectory()) + .setLoggingLevel(Config.LoggingLevel.VERBOSE) + .setDeviceIdStrategy(Config.DeviceIdStrategy.UUID) + .enableFeatures(Config.Feature.Events, Config.Feature.Sessions) + .setEventQueueSizeToSend(1); + } + + // ==================== Scenario tests ==================== + + /** + * Helper: starts a server with the given response, inits the SDK, records an event, + * waits for the request round-trip, and asserts isSending() is false. + */ + private void assertResponseDoesNotDeadlock(int code, String body) throws Exception { + startServer(() -> new Response(code, body)); + + Countly.instance().init(configForLocalServer()); + Countly.session().begin(); + + Countly.instance().events().recordEvent("test_event"); + Thread.sleep(2000); + + Assert.assertFalse( + "SDK networking should NOT be stuck after response [" + code + ": " + body.substring(0, Math.min(body.length(), 40)) + "]", + SDKCore.instance.networking.isSending() + ); + } + + /** + * Server returns HTML 502 error — the primary scenario from issue #264. + * Before the fix: JSONException propagates, executor deadlocks permanently. + */ + @Test + public void html502Response_sdkDoesNotDeadlock() throws Exception { + assertResponseDoesNotDeadlock(502, "

502 Bad Gateway

"); + } + + /** Plain text response from a load balancer should not deadlock. */ + @Test + public void plainTextResponse_sdkDoesNotDeadlock() throws Exception { + assertResponseDoesNotDeadlock(200, "OK"); + } + + /** Empty response body should not deadlock. */ + @Test + public void emptyResponse_sdkDoesNotDeadlock() throws Exception { + assertResponseDoesNotDeadlock(200, ""); + } + + /** Valid JSON with non-2xx code should not deadlock. */ + @Test + public void jsonErrorResponse_sdkDoesNotDeadlock() throws Exception { + assertResponseDoesNotDeadlock(500, "{\"result\":\"Internal Server Error\"}"); + } + + /** + * Scenario: Server initially returns HTML, then starts returning valid JSON. + * Verifies the SDK can resume sending after transient 502 errors. + */ + @Test + public void serverRecovery_sdkResumesSending() throws Exception { + AtomicInteger requestCount = new AtomicInteger(0); + AtomicInteger successCount = new AtomicInteger(0); + + startServer(() -> { + int count = requestCount.incrementAndGet(); + if (count <= 1) { + return new Response(502, "

502 Bad Gateway

"); + } else { + successCount.incrementAndGet(); + return new Response(200, "{\"result\":\"Success\"}"); + } + }); + + Countly.instance().init(configForLocalServer()); + Countly.session().begin(); + Thread.sleep(2000); + + Assert.assertFalse( + "SDK should recover from 502 HTML response", + SDKCore.instance.networking.isSending() + ); + + for (int i = 0; i < 5; i++) { + if (!SDKCore.instance.networking.isSending()) { + SDKCore.instance.networking.check(SDKCore.instance.config); + } + Thread.sleep(1000); + } + + Assert.assertTrue( + "SDK should have sent requests after server recovered (got " + successCount.get() + " successes)", + successCount.get() > 0 + ); + } + + /** + * Scenario: Server closes connection abruptly without sending a response body. + * Transport.response() returns null, which used to cause NPE in processResponse(). + * The catch(Exception) in send() is the last safety net for this path. + */ + @Test + public void connectionReset_sdkDoesNotDeadlock() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + port = server.getAddress().getPort(); + server.createContext("/", exchange -> { + // Send headers but close immediately — some JVMs produce IOException, + // others may produce unexpected exceptions in the response reader + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + }); + server.start(); + + Countly.instance().init(configForLocalServer()); + Countly.session().begin(); + + Countly.instance().events().recordEvent("test_event"); + Thread.sleep(2000); + + Assert.assertFalse( + "SDK networking should NOT be stuck after abrupt connection close", + SDKCore.instance.networking.isSending() + ); + } + + // ==================== Helpers ==================== + + @FunctionalInterface + interface ServerBehavior { + Response respond(); + } + + static class Response { + final int code; + final String body; + + Response(int code, String body) { + this.code = code; + this.body = body; + } + } +} diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksExceptionRecoveryTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksExceptionRecoveryTests.java new file mode 100644 index 00000000..af67ec05 --- /dev/null +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/TasksExceptionRecoveryTests.java @@ -0,0 +1,290 @@ +package ly.count.sdk.java.internal; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Tests that the Tasks executor properly recovers from exceptions thrown by tasks. + * These tests verify the fix for issue #264 where an uncaught exception in a task + * would leave the "running" state permanently set, causing a networking deadlock. + */ +@RunWith(JUnit4.class) +public class TasksExceptionRecoveryTests { + private Tasks tasks; + + @Before + public void setUp() { + tasks = new Tasks("test-recovery", null); + } + + @After + public void tearDown() { + tasks.shutdown(); + } + + /** + * When a task throws a RuntimeException, the executor should recover + * and isRunning() should return false after the task completes. + * This is the core scenario from issue #264. + */ + @Test + public void taskRuntimeException_executorRecovers() throws Exception { + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() { + throw new RuntimeException("Simulated JSONException from non-JSON response"); + } + }); + + Thread.sleep(200); + Assert.assertFalse("Executor should not be stuck in running state after RuntimeException", tasks.isRunning()); + } + + /** + * After a task throws an exception, the executor should be able to + * successfully run subsequent tasks. + */ + @Test + public void taskException_subsequentTaskSucceeds() throws Exception { + // First task: throws exception + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() { + throw new RuntimeException("Simulated failure"); + } + }); + + Thread.sleep(200); + + // Second task: should succeed + final boolean[] secondTaskRan = { false }; + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() { + secondTaskRan[0] = true; + return true; + } + }); + + Thread.sleep(200); + Assert.assertTrue("Subsequent task should have executed after previous task threw exception", secondTaskRan[0]); + Assert.assertFalse("Executor should not be running after second task completes", tasks.isRunning()); + } + + /** + * When a task throws a NullPointerException (e.g., from SDKCore.instance being null), + * the executor should recover. + */ + @Test + public void taskNullPointerException_executorRecovers() throws Exception { + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() { + String nullStr = null; + nullStr.length(); // throws NPE + return true; + } + }); + + Thread.sleep(200); + Assert.assertFalse("Executor should recover from NullPointerException", tasks.isRunning()); + } + + /** + * When a task with a non-zero ID throws an exception, it should be + * removed from the pending map so new tasks with the same ID can be submitted. + */ + @Test + public void taskWithId_exceptionClearsPending() throws Exception { + Long taskId = 42L; + + // First task with ID: throws exception + tasks.run(new Tasks.Task(taskId) { + @Override + public Boolean call() { + throw new RuntimeException("Simulated failure"); + } + }); + + Thread.sleep(200); + + // Second task with same ID: should be accepted and run (not deduplicated against the failed one) + final boolean[] secondTaskRan = { false }; + Future future = tasks.run(new Tasks.Task(taskId) { + @Override + public Boolean call() { + secondTaskRan[0] = true; + return true; + } + }); + + Thread.sleep(200); + Assert.assertTrue("Task with same ID should run after previous one failed", secondTaskRan[0]); + Assert.assertFalse("Executor should not be running", tasks.isRunning()); + } + + /** + * When a callback throws an exception, the executor should still recover + * and not deadlock. The callback runs inside the try block, so its exception + * is caught by the finally block. + */ + @Test + public void callbackException_executorRecovers() throws Exception { + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() { + return true; + } + }, result -> { + throw new RuntimeException("Simulated callback failure"); + }); + + Thread.sleep(200); + Assert.assertFalse("Executor should recover from callback exception", tasks.isRunning()); + } + + /** + * After a callback throws an exception, subsequent tasks should still execute. + */ + @Test + public void callbackException_subsequentTaskSucceeds() throws Exception { + // First task: succeeds but callback throws + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() { + return true; + } + }, result -> { + throw new RuntimeException("Callback failure"); + }); + + Thread.sleep(200); + + // Second task: should succeed + final boolean[] secondTaskRan = { false }; + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() { + secondTaskRan[0] = true; + return true; + } + }); + + Thread.sleep(200); + Assert.assertTrue("Task should run after previous callback threw exception", secondTaskRan[0]); + } + + /** + * Multiple consecutive failing tasks should not accumulate stuck state. + * The executor should recover after each one. + */ + @Test + public void multipleConsecutiveFailures_executorRecovers() throws Exception { + for (int i = 0; i < 5; i++) { + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() { + throw new RuntimeException("Failure #" + System.currentTimeMillis()); + } + }); + } + + Thread.sleep(500); + Assert.assertFalse("Executor should recover after multiple consecutive failures", tasks.isRunning()); + + // Verify executor still works + final boolean[] taskRan = { false }; + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() { + taskRan[0] = true; + return true; + } + }); + + Thread.sleep(200); + Assert.assertTrue("Executor should still work after multiple failures", taskRan[0]); + } + + /** + * When a task throws a checked Exception (e.g. IOException), the executor + * should recover. The original bug equally applied to checked exceptions since + * the cleanup code was not in a finally block. The ExecutorService wraps + * checked exceptions in ExecutionException, and running must still be reset. + */ + @Test + public void taskCheckedException_executorRecovers() throws Exception { + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() throws Exception { + throw new IOException("Simulated I/O failure during request"); + } + }); + + Thread.sleep(200); + Assert.assertFalse("Executor should recover from checked IOException", tasks.isRunning()); + + // Verify executor still works after checked exception + final boolean[] taskRan = { false }; + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() { + taskRan[0] = true; + return true; + } + }); + + Thread.sleep(200); + Assert.assertTrue("Subsequent task should run after checked exception", taskRan[0]); + } + + /** + * Deterministic test for volatile correctness of the "running" field. + * Uses a CountDownLatch instead of Thread.sleep to verify that the calling + * thread sees running=null immediately after the task signals completion. + * Without volatile, a stale cached value could cause isRunning() to return + * true even though the executor thread already set running=null. + */ + @Test + public void volatileCorrectness_isRunningVisibleAcrossThreads() throws Exception { + CountDownLatch taskStarted = new CountDownLatch(1); + CountDownLatch taskCanFinish = new CountDownLatch(1); + + // Submit a task that signals when it starts, then waits for permission to finish + tasks.run(new Tasks.Task(0L) { + @Override + public Boolean call() throws Exception { + taskStarted.countDown(); + taskCanFinish.await(5, TimeUnit.SECONDS); + return true; + } + }); + + // Wait for the task to start executing on the executor thread + Assert.assertTrue("Task should have started", taskStarted.await(2, TimeUnit.SECONDS)); + + // While the task is running, isRunning() must be true + Assert.assertTrue("isRunning() should be true while task is executing", tasks.isRunning()); + + // Allow the task to finish + taskCanFinish.countDown(); + + // Submit a no-op task and wait for it — this guarantees the previous task + // (including its finally block) has fully completed + tasks.await(); + + // Without volatile, this read from the test thread could see the stale value + Assert.assertFalse( + "isRunning() should be false immediately after task completes (volatile visibility)", + tasks.isRunning() + ); + } +} diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/TransportTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/TransportTests.java new file mode 100644 index 00000000..6afbfd19 --- /dev/null +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/TransportTests.java @@ -0,0 +1,138 @@ +package ly.count.sdk.java.internal; + +import ly.count.sdk.java.Config; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.lang.reflect.Field; + +@RunWith(JUnit4.class) +public class TransportTests { + + private Transport transport; + + @Before + public void setUp() throws Exception { + transport = new Transport(); + Log L = new Log(Config.LoggingLevel.VERBOSE, null); + Field logField = Transport.class.getDeclaredField("L"); + logField.setAccessible(true); + logField.set(transport, L); + } + + // ==================== processResponse tests ==================== + + /** + * Valid JSON response with "result" key and 200 status code + * should return true (success) + */ + @Test + public void processResponse_validJsonSuccess() { + Boolean result = transport.processResponse(200, "{\"result\":\"Success\"}", 1L); + Assert.assertTrue(result); + } + + /** + * Valid JSON response with "result" key and various 2xx status codes + * should return true (success) + */ + @Test + public void processResponse_validJson2xxRange() { + Assert.assertTrue(transport.processResponse(200, "{\"result\":\"ok\"}", 1L)); + Assert.assertTrue(transport.processResponse(201, "{\"result\":\"created\"}", 2L)); + Assert.assertTrue(transport.processResponse(299, "{\"result\":\"ok\"}", 3L)); + } + + /** + * Valid JSON response with "result" key but non-2xx status code + * should return false (failure) + */ + @Test + public void processResponse_validJsonNon2xxCode() { + Assert.assertFalse(transport.processResponse(400, "{\"result\":\"Bad Request\"}", 1L)); + Assert.assertFalse(transport.processResponse(500, "{\"result\":\"Internal Server Error\"}", 2L)); + Assert.assertFalse(transport.processResponse(302, "{\"result\":\"redirect\"}", 3L)); + Assert.assertFalse(transport.processResponse(199, "{\"result\":\"ok\"}", 4L)); + } + + /** + * Valid JSON response but missing "result" key with 200 status code + * should return false (failure) + */ + @Test + public void processResponse_validJsonMissingResultKey() { + Assert.assertFalse(transport.processResponse(200, "{\"error\":\"something\"}", 1L)); + Assert.assertFalse(transport.processResponse(200, "{}", 2L)); + } + + /** + * Null response should return false without throwing NPE + * This was the original bug path — response() returns null on IOException + */ + @Test + public void processResponse_nullResponse() { + Boolean result = transport.processResponse(200, null, 1L); + Assert.assertFalse(result); + } + + /** + * HTML response (e.g., 502/503 error page) should return false + * This is the primary scenario from issue #264 + */ + @Test + public void processResponse_htmlResponse() { + String html502 = "

502 Bad Gateway

"; + Boolean result = transport.processResponse(502, html502, 1L); + Assert.assertFalse(result); + } + + /** + * Plain text non-JSON response should return false + */ + @Test + public void processResponse_plainTextResponse() { + Boolean result = transport.processResponse(200, "OK", 1L); + Assert.assertFalse(result); + } + + /** + * Empty string response should return false + */ + @Test + public void processResponse_emptyStringResponse() { + Boolean result = transport.processResponse(200, "", 1L); + Assert.assertFalse(result); + } + + /** + * Malformed JSON should return false without propagating JSONException + */ + @Test + public void processResponse_malformedJson() { + Assert.assertFalse(transport.processResponse(200, "{invalid json", 1L)); + Assert.assertFalse(transport.processResponse(200, "not json at all", 2L)); + Assert.assertFalse(transport.processResponse(200, "{{{{", 3L)); + } + + /** + * Edge case: JSON array instead of JSON object should return false + */ + @Test + public void processResponse_jsonArrayResponse() { + Boolean result = transport.processResponse(200, "[{\"result\":\"ok\"}]", 1L); + Assert.assertFalse(result); + } + + /** + * Boundary status codes around the 200-299 range + */ + @Test + public void processResponse_boundaryStatusCodes() { + Assert.assertTrue(transport.processResponse(200, "{\"result\":\"ok\"}", 1L)); + Assert.assertTrue(transport.processResponse(299, "{\"result\":\"ok\"}", 2L)); + Assert.assertFalse(transport.processResponse(199, "{\"result\":\"ok\"}", 3L)); + Assert.assertFalse(transport.processResponse(300, "{\"result\":\"ok\"}", 4L)); + } +} From 6c93c73ae10c3cff27cef27fab30de7cafc0a845 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 15 Apr 2026 22:46:37 +0300 Subject: [PATCH 31/33] feat: reproduce issue 264 --- app-java/build.gradle | 2 +- .../ly/count/java/demo/ReproduceIssue264.java | 179 ++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 app-java/src/main/java/ly/count/java/demo/ReproduceIssue264.java diff --git a/app-java/build.gradle b/app-java/build.gradle index 6c0872f6..3b90276a 100644 --- a/app-java/build.gradle +++ b/app-java/build.gradle @@ -16,4 +16,4 @@ dependencies { //implementation "ly.count.sdk:java:${CLY_VERSION}" } -mainClassName = 'ly.count.java.demo.Sample' +mainClassName = 'ly.count.java.demo.ReproduceIssue264' diff --git a/app-java/src/main/java/ly/count/java/demo/ReproduceIssue264.java b/app-java/src/main/java/ly/count/java/demo/ReproduceIssue264.java new file mode 100644 index 00000000..d7b23963 --- /dev/null +++ b/app-java/src/main/java/ly/count/java/demo/ReproduceIssue264.java @@ -0,0 +1,179 @@ +package ly.count.java.demo; + +import com.sun.net.httpserver.HttpServer; +import java.io.File; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicInteger; +import ly.count.sdk.java.Config; +import ly.count.sdk.java.Countly; + +/** + * Reproduces GitHub Issue #264: + * "Non-JSON Server Response Causes Permanent Networking Deadlock" + * + * This app starts a local HTTP server that returns HTML (simulating a 502 error page), + * initializes the Countly SDK against it, records events, and checks whether the SDK + * gets permanently stuck. + * + * Run with: ./gradlew app-java:run + * (after setting mainClassName = 'ly.count.java.demo.ReproduceIssue264' in app-java/build.gradle) + */ +public class ReproduceIssue264 { + + public static void main(String[] args) throws Exception { + AtomicInteger requestCount = new AtomicInteger(0); + AtomicInteger successCount = new AtomicInteger(0); + + // Start a local HTTP server that returns HTML for the first 3 requests, + // then valid JSON for subsequent requests (simulating server recovery) + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + int port = server.getAddress().getPort(); + + server.createContext("/", exchange -> { + int count = requestCount.incrementAndGet(); + String body; + int code; + + if (count <= 3) { + code = 502; + body = "

502 Bad Gateway

The server is temporarily unavailable.

"; + System.out.println("[Mock Server] Request #" + count + " -> returning HTML 502 (simulating outage)"); + } else { + code = 200; + body = "{\"result\":\"Success\"}"; + successCount.incrementAndGet(); + System.out.println("[Mock Server] Request #" + count + " -> returning JSON 200 (server recovered)"); + } + + exchange.sendResponseHeaders(code, body.length()); + OutputStream os = exchange.getResponseBody(); + os.write(body.getBytes()); + os.close(); + }); + + server.start(); + System.out.println("=== Issue #264 Reproduction ==="); + System.out.println("[Mock Server] Started on port " + port); + System.out.println(); + + // Setup SDK storage directory + String[] sdkStorageRootPath = { System.getProperty("user.home"), "__COUNTLY", "java_issue264" }; + File sdkStorageRootDirectory = new File(String.join(File.separator, sdkStorageRootPath)); + if (!(sdkStorageRootDirectory.exists() && sdkStorageRootDirectory.isDirectory())) { + sdkStorageRootDirectory.mkdirs(); + } + + // Initialize SDK pointing to our mock server + Config config = new Config("http://localhost:" + port, "TEST_APP_KEY", sdkStorageRootDirectory) + .setLoggingLevel(Config.LoggingLevel.WARN) + .setDeviceIdStrategy(Config.DeviceIdStrategy.UUID) + .enableFeatures(Config.Feature.Events, Config.Feature.Sessions) + .setEventQueueSizeToSend(1); + + Countly.instance().init(config); + System.out.println("[SDK] Initialized against mock server"); + + // Start session (triggers first request -> will get HTML 502) + Countly.session().begin(); + System.out.println("[SDK] Session started"); + + // Record an event (triggers another request -> will get HTML 502) + Countly.instance().events().recordEvent("test_event_during_outage"); + System.out.println("[SDK] Event recorded"); + + // Wait for requests to be attempted + System.out.println(); + System.out.println("[Test] Waiting 3 seconds for initial requests..."); + Thread.sleep(3000); + + // Check if SDK is deadlocked via reflection (SDKCore.instance.networking is protected) + boolean isSending = isNetworkingSending(); + + System.out.println(); + System.out.println("============================================================"); + if (isSending) { + System.out.println(" BUG REPRODUCED: isSending() = true (DEADLOCKED!)"); + System.out.println(" The SDK is permanently stuck. No further requests"); + System.out.println(" will ever be sent, even when the server recovers."); + } else { + System.out.println(" FIX CONFIRMED: isSending() = false (recovered)"); + System.out.println(" The SDK handled the non-JSON response gracefully."); + } + System.out.println("============================================================"); + System.out.println(); + + // Try to trigger recovery by calling check + System.out.println("[Test] Triggering networking check cycles (server now returns JSON)..."); + triggerNetworkingChecks(5); + + int totalRequests = requestCount.get(); + int successes = successCount.get(); + + System.out.println(); + System.out.println("============================================================"); + System.out.println(" Total requests received by server: " + totalRequests); + System.out.println(" Successful (JSON 200) responses: " + successes); + if (successes > 0) { + System.out.println(" SDK successfully retried after server recovered!"); + } else if (!isNetworkingSending()) { + System.out.println(" SDK recovered from error. Requests will retry on"); + System.out.println(" the next timer tick (no deadlock)."); + } else { + System.out.println(" SDK is STILL deadlocked. Bug confirmed."); + } + System.out.println("============================================================"); + + // Cleanup + Countly.instance().stop(); + server.stop(0); + System.out.println(); + System.out.println("[Done] Cleanup complete."); + } + + /** + * Access SDKCore.instance.networking.isSending() via reflection + * since these fields are protected/package-private. + */ + private static boolean isNetworkingSending() throws Exception { + Class sdkCoreClass = Class.forName("ly.count.sdk.java.internal.SDKCore"); + Field instanceField = sdkCoreClass.getDeclaredField("instance"); + instanceField.setAccessible(true); + Object sdkCore = instanceField.get(null); + + Field networkingField = sdkCoreClass.getDeclaredField("networking"); + networkingField.setAccessible(true); + Object networking = networkingField.get(sdkCore); + + return (boolean) networking.getClass().getMethod("isSending").invoke(networking); + } + + /** + * Trigger SDKCore.instance.networking.check(config) via reflection. + */ + private static void triggerNetworkingChecks(int count) throws Exception { + Class sdkCoreClass = Class.forName("ly.count.sdk.java.internal.SDKCore"); + Field instanceField = sdkCoreClass.getDeclaredField("instance"); + instanceField.setAccessible(true); + Object sdkCore = instanceField.get(null); + + Field networkingField = sdkCoreClass.getDeclaredField("networking"); + networkingField.setAccessible(true); + Object networking = networkingField.get(sdkCore); + + Field configField = sdkCoreClass.getDeclaredField("config"); + configField.setAccessible(true); + Object internalConfig = configField.get(sdkCore); + + java.lang.reflect.Method checkMethod = networking.getClass().getMethod("check", + Class.forName("ly.count.sdk.java.internal.InternalConfig")); + + for (int i = 0; i < count; i++) { + if (!isNetworkingSending()) { + checkMethod.invoke(networking, internalConfig); + } + Thread.sleep(1000); + } + } +} \ No newline at end of file From 25dd55770bfa72f776842d7b6e26ad436fa1c23a Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 15 Apr 2026 22:47:09 +0300 Subject: [PATCH 32/33] fix: issue 264 --- .../ly/count/sdk/java/internal/Tasks.java | 24 ++++++++++-------- .../ly/count/sdk/java/internal/Transport.java | 25 ++++++++++++++----- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/Tasks.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/Tasks.java index 9d165970..6eb7cb8c 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/Tasks.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/Tasks.java @@ -22,7 +22,7 @@ public class Tasks { * Service which runs {@link Callable}s */ private final ExecutorService executor; - private Long running = null; + private volatile Long running = null; /** * Map of {@link Future}s for {@link Callable}s not yet resolved @@ -92,18 +92,20 @@ Future run(final Task task, final Callback callback) { @Override public T call() throws Exception { running = task.id; - T result = task.call(); - synchronized (pending) { - if (!task.id.equals(0L)) { - pending.remove(task.id); + try { + T result = task.call(); + if (callback != null) { + callback.call(result); + } + return result; + } finally { + synchronized (pending) { + if (!task.id.equals(0L)) { + pending.remove(task.id); + } + running = null; } - running = null; - // L.d("pending " + pending.keySet() + ", done running " + task.id); - } - if (callback != null) { - callback.call(result); } - return result; } }); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/Transport.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/Transport.java index b016a4c1..0b7957f7 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/Transport.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/Transport.java @@ -342,6 +342,9 @@ public Boolean send() { } catch (IOException e) { L.w("[network] Error while sending request " + request + " " + e); return false; + } catch (Exception e) { + L.e("[network] Unexpected error while sending request " + request + " " + e); + return false; } finally { if (connection != null) { connection.disconnect(); @@ -354,12 +357,22 @@ public Boolean send() { Boolean processResponse(int code, String response, Long requestId) { L.i("[network] [processResponse] Code [" + code + "] response [" + response + "] for request[" + requestId + "]"); - JSONObject jsonObject = new JSONObject(response); - if (code >= 200 && code < 300 && jsonObject.has("result")) { - L.d("[network] Success"); - return true; - } else { - L.w("[network] Fail: code :" + code + ", result: " + response); + if (response == null) { + L.w("[network] Null response for request [" + requestId + "]"); + return false; + } + + try { + JSONObject jsonObject = new JSONObject(response); + if (code >= 200 && code < 300 && jsonObject.has("result")) { + L.d("[network] Success"); + return true; + } else { + L.w("[network] Fail: code :" + code + ", result: " + response); + return false; + } + } catch (Exception e) { + L.w("[network] Failed to parse response as JSON for request [" + requestId + "], response: [" + response + "], error: [" + e.getMessage() + "]"); return false; } } From c9aa6a7681178d99d0689b3caeb85f4d61ada13d Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 16 Apr 2026 11:52:10 +0300 Subject: [PATCH 33/33] feat: 24.1.5 --- CHANGELOG.md | 2 +- build.gradle | 2 +- gradle.properties | 2 +- sdk-java/src/main/java/ly/count/sdk/java/Config.java | 2 +- .../src/test/java/ly/count/sdk/java/internal/TestUtils.java | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b13c5b5d..b2431f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## XX.XX.XX +## 24.1.5 * Fixed a bug where a non-JSON server response would cause a permanent networking deadlock, preventing all subsequent requests from being sent. * Fixed a bug where a NullPointerException in SDKCore.recover() would permanently block SDK initialization when a crash file from a previous session existed on disk. diff --git a/build.gradle b/build.gradle index c2566abe..2c3b316d 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { } allprojects { - ext.CLY_VERSION = "24.1.4" + ext.CLY_VERSION = "24.1.5" ext.POWERMOCK_VERSION = "1.7.4" tasks.withType(Javadoc) { diff --git a/gradle.properties b/gradle.properties index 44c13b92..77c0d779 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,7 +18,7 @@ # org.gradle.parallel=true # RELEASE FIELD SECTION -VERSION_NAME=24.1.4 +VERSION_NAME=24.1.5 GROUP=ly.count.sdk POM_URL=https://github.com/Countly/countly-sdk-java diff --git a/sdk-java/src/main/java/ly/count/sdk/java/Config.java b/sdk-java/src/main/java/ly/count/sdk/java/Config.java index abe0348a..cf5df62f 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/Config.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/Config.java @@ -68,7 +68,7 @@ public class Config { /** * Countly SDK version to be sent in HTTP requests */ - protected String sdkVersion = "24.1.4"; + protected String sdkVersion = "24.1.5"; /** * Countly SDK version to be sent in HTTP requests diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java index 55301e82..2122c863 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/TestUtils.java @@ -39,7 +39,7 @@ public class TestUtils { static String SERVER_APP_KEY = "COUNTLY_APP_KEY"; static String DEVICE_ID = "some_random_test_device_id"; static String SDK_NAME = "java-native"; - static String SDK_VERSION = "24.1.4"; + static String SDK_VERSION = "24.1.5"; static String APPLICATION_VERSION = "1.0"; public static final String[] eKeys = new String[] { "eventKey1", "eventKey2", "eventKey3", "eventKey4", "eventKey5", "eventKey6", "eventKey7" };