Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
26b1929
feat: add check to prevent empty user details
arifBurakDemiray Nov 24, 2025
0d63a43
feat: save events before user props sending
arifBurakDemiray Nov 24, 2025
d304bbf
feat: user prop save on timer and without merge
arifBurakDemiray Nov 24, 2025
fe7add1
feat: user prop save on event recording
arifBurakDemiray Nov 24, 2025
7b3517b
feat: user prop auto-save changelog
arifBurakDemiray Nov 24, 2025
9df4943
feat: user props on sessions
arifBurakDemiray Nov 24, 2025
a52b71f
feat: user props on sessions changelog
arifBurakDemiray Nov 24, 2025
16e0ef7
fix: build gradle
arifBurakDemiray Nov 25, 2025
c11abfb
fix: build gradle publish plugin
arifBurakDemiray Nov 25, 2025
712135e
fix: build gradle publish plugin
arifBurakDemiray Nov 25, 2025
0ef0a90
chore: add comment to it
arifBurakDemiray Nov 25, 2025
a362a5f
feat: reverse config
arifBurakDemiray Dec 3, 2025
97e6094
feat: make it reversable
arifBurakDemiray Dec 3, 2025
b91bdfa
feat: make it reversable
arifBurakDemiray Dec 3, 2025
e85b36b
feat: validate sessions trigger sending user props
arifBurakDemiray Dec 5, 2025
acdc4a7
feat: tests for the user props auto save
arifBurakDemiray Dec 5, 2025
3a07203
Update build.gradle
arifBurakDemiray Dec 5, 2025
2926827
Update build.gradle
arifBurakDemiray Dec 5, 2025
10001b1
Update CHANGELOG.md
arifBurakDemiray Dec 5, 2025
d7b3fe6
Merge pull request #261 from Countly/fix_root_build_gradle
turtledreams Dec 5, 2025
1924def
feat: with disabled using
arifBurakDemiray Dec 5, 2025
834571e
Merge branch 'staging' into user_props_on_sessions
arifBurakDemiray Dec 5, 2025
7ec98f9
Merge pull request #259 from Countly/user_props_on_sessions
turtledreams Dec 5, 2025
cf4fa09
fix: missing call
arifBurakDemiray Dec 5, 2025
3d3ae24
Merge branch 'staging' into auto_user_props_save
arifBurakDemiray Dec 5, 2025
cb538c0
fix: gather all under one flag
arifBurakDemiray Dec 5, 2025
993c127
feat: 24.1.4
arifBurakDemiray Dec 5, 2025
b85cbbe
Merge pull request #258 from Countly/auto_user_props_save
turtledreams Dec 5, 2025
e6011aa
Merge pull request #262 from Countly/2414
turtledreams Dec 5, 2025
0365369
fix: npe when clutter crash
arifBurakDemiray Apr 15, 2026
222bdc7
fix: npe when clutter crash: changelog
arifBurakDemiray Apr 15, 2026
d31a4c1
fix: issue reproduce app
arifBurakDemiray Apr 15, 2026
904df35
feat: tests
arifBurakDemiray Apr 15, 2026
f885609
feat: upgrade mockito
arifBurakDemiray Apr 15, 2026
06d1f93
feat: changelog for deadlock bug
arifBurakDemiray Apr 15, 2026
0485be8
feat: tests about it
arifBurakDemiray Apr 15, 2026
6c93c73
feat: reproduce issue 264
arifBurakDemiray Apr 15, 2026
25dd557
fix: issue 264
arifBurakDemiray Apr 15, 2026
05e84f1
Merge pull request #265 from Countly/mockito_upgrade
arifBurakDemiray Apr 16, 2026
d79b4ac
Merge pull request #266 from Countly/fix/263-recover-npe-init-ordering
arifBurakDemiray Apr 16, 2026
dea12f1
Merge branch 'staging' into fix/264-networking-deadlock-recovery
arifBurakDemiray Apr 16, 2026
44c6066
Merge pull request #267 from Countly/fix/264-networking-deadlock-reco…
arifBurakDemiray Apr 16, 2026
c9aa6a7
feat: 24.1.5
arifBurakDemiray Apr 16, 2026
3eac370
Merge pull request #268 from Countly/2415
arifBurakDemiray Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
## 24.1.3
## 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.

## 24.1.4
* ! 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
* When a session call made
* 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.
Expand Down Expand Up @@ -214,3 +226,4 @@

* initial SDK release
* MavenCentral rerelease

2 changes: 1 addition & 1 deletion app-java/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ dependencies {
//implementation "ly.count.sdk:java:${CLY_VERSION}"
}

mainClassName = 'ly.count.java.demo.Sample'
mainClassName = 'ly.count.java.demo.ReproduceIssue264'
118 changes: 118 additions & 0 deletions app-java/src/main/java/ly/count/java/demo/ReproduceIssue263.java
Original file line number Diff line number Diff line change
@@ -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.");
}
}
}
179 changes: 179 additions & 0 deletions app-java/src/main/java/ly/count/java/demo/ReproduceIssue264.java
Original file line number Diff line number Diff line change
@@ -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 = "<html><body><h1>502 Bad Gateway</h1><p>The server is temporarily unavailable.</p></body></html>";
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);
}
}
}
42 changes: 13 additions & 29 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -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.5"
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()
}
}
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
# org.gradle.parallel=true

# RELEASE FIELD SECTION
VERSION_NAME=24.1.3
VERSION_NAME=24.1.5
GROUP=ly.count.sdk

POM_URL=https://github.com/Countly/countly-sdk-java
Expand Down
Loading
Loading