Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0cf6ccf
fix: cross-SDK test compatibility fixes
joalves Jan 19, 2026
e399c35
fix: remove mutation during read lock in audienceMatches
joalves Jan 20, 2026
419c47e
feat: add comprehensive test coverage improvements
joalves Jan 27, 2026
935a36b
docs: add Spring Boot, Android examples and cancellation patterns to …
joalves Jan 30, 2026
07c067d
fix: remove blocking calls and resource leaks from Java Android examples
joalves Jan 30, 2026
12365ed
test: add canonical test parity (265 → 294 tests)
joalves Feb 6, 2026
38327cb
fix: resolve critical bugs for 100% test pass rate
joalves Feb 21, 2026
a7a3604
fix: correct IN operator arg order and null equality handling
joalves Feb 22, 2026
76e0293
feat: add convenience ABsmartly.create(endpoint, apiKey, app, env) fa…
joalves Feb 26, 2026
e4b79c0
refactor: replace positional create() factory with Builder pattern
joalves Feb 27, 2026
05a8039
docs: update README documentation
joalves Mar 9, 2026
61dd56c
fix: restore events on publish failure and allow override after close
joalves Mar 15, 2026
b9d48f6
feat(java-sdk): add readyError() method to Context
joalves Mar 15, 2026
f66822a
feat(java-sdk): add @Deprecated finalize aliases and standardize erro…
joalves Mar 15, 2026
980f8fb
fix: read methods return safe defaults when context not ready or closed
joalves Mar 15, 2026
4e1d396
feat: cross-SDK consistency fixes — all 201 scenarios passing
joalves Mar 17, 2026
ba6c3e5
refactor: rename ContextEventHandler to ContextPublisher
joalves Mar 18, 2026
2b2775c
fix: address coderabbit review issues
joalves Mar 18, 2026
c70c02d
fix: add setContextPublisher to deprecated ABSmartlyConfig
joalves Mar 18, 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
203 changes: 203 additions & 0 deletions .claude/tasks/fix-plan.md

Large diffs are not rendered by default.

725 changes: 582 additions & 143 deletions README.md

Large diffs are not rendered by default.

20 changes: 8 additions & 12 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ plugins {
id "java"
id "groovy"
id "jacoco"
id "findbugs"

id "com.diffplug.spotless" version "5.8.2"
id "com.adarshr.test-logger" version "2.1.1"
id "org.barfuin.gradle.jacocolog" version "1.2.3"
id "io.github.gradle-nexus.publish-plugin" version "1.1.0"
id "org.owasp.dependencycheck" version "7.3.0"
id "com.diffplug.spotless" version "6.25.0"
id "com.adarshr.test-logger" version "4.0.0"
id "org.barfuin.gradle.jacocolog" version "3.1.0"
id "io.github.gradle-nexus.publish-plugin" version "1.3.0"
}


Expand All @@ -21,26 +19,24 @@ ext {
jacksonVersion = "2.13.4.2"
jacksonDataTypeVersion = "2.13.4"

junitVersion = "5.7.0"
mockitoVersion = "3.6.28"
junitVersion = "5.10.2"
mockitoVersion = "5.11.0"
}


allprojects {
group = GROUP_ID

apply plugin: "java"
apply plugin: "org.owasp.dependencycheck"
apply from: rootProject.file("gradle/repositories.gradle")
apply from: rootProject.file("gradle/spotless.gradle")
apply from: rootProject.file("gradle/findbugs.gradle")
apply from: rootProject.file("gradle/test-logger.gradle")
apply from: rootProject.file("gradle/jacoco.gradle")
apply from: rootProject.file("gradle/coverage-logger.gradle")

compileJava {
sourceCompatibility = "1.6"
targetCompatibility = "1.6"
sourceCompatibility = "1.8"
targetCompatibility = "1.8"
Comment on lines +38 to +39
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document the Java 6/7 → 8 bytecode break in the release notes.

Bumping sourceCompatibility and targetCompatibility from 1.6 to 1.8 changes the published JAR's minimum required JVM from Java 6 to Java 8. Any downstream consumer still running Java 6 or 7 will fail to load the SDK at class-loading time. This is a binary-breaking change and should be called out explicitly in the changelog or release notes alongside the version bump.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build.gradle` around lines 38 - 39, The build now sets sourceCompatibility
and targetCompatibility to "1.8", which raises the published JAR's minimum JVM
requirement from Java 6/7 to Java 8; add a clear changelog/release-notes entry
calling out this binary-breaking change so downstream users are warned. Update
your release notes (or CHANGELOG) to state that
sourceCompatibility/targetCompatibility were bumped to 1.8, that older JVMs
(Java 6 and 7) will fail at class-load time, and include migration guidance
(e.g., upgrade JVM to Java 8 or use an earlier SDK release). Ensure the note
references the change to sourceCompatibility and targetCompatibility so it’s
discoverable.

}
}

Expand Down
19 changes: 12 additions & 7 deletions core-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,21 @@ dependencies {
testImplementation group: "org.junit.jupiter", name: "junit-jupiter-params", version: junitVersion
testRuntimeOnly group: "org.junit.jupiter", name: "junit-jupiter-engine", version: junitVersion
testImplementation group: "org.mockito", name: "mockito-core", version: mockitoVersion
testImplementation group: "org.mockito", name: "mockito-inline", version: mockitoVersion
testImplementation group: "org.mockito", name: "mockito-junit-jupiter", version: mockitoVersion
}

check.dependsOn jacocoTestCoverageVerification

def jacocoExcludes = [
"com/absmartly/sdk/json/**/*",
"com/absmartly/sdk/deprecated/**/*",
"com/absmartly/sdk/java/**/*",
]

jacocoTestReport {
afterEvaluate {
getClassDirectories().setFrom(classDirectories.files.collect {
fileTree(dir: it, exclude: [
"com/absmartly/core-api/json/**/*"
])
fileTree(dir: it, exclude: jacocoExcludes)
})
}
}
Expand Down Expand Up @@ -67,16 +70,18 @@ jacocoTestCoverageVerification {

afterEvaluate {
getClassDirectories().setFrom(classDirectories.files.collect {
fileTree(dir: it, exclude: [
"com/absmartly/core-api/json/**/*"
])
fileTree(dir: it, exclude: jacocoExcludes)
})
}
}


test {
useJUnitPlatform()
jvmArgs '--add-opens', 'java.base/java.lang=ALL-UNNAMED',
'--add-opens', 'java.base/java.lang.reflect=ALL-UNNAMED',
'--add-opens', 'java.base/java.util=ALL-UNNAMED',
'--add-opens', 'java.base/java.util.concurrent=ALL-UNNAMED'
}

publishToSonatype.dependsOn check
95 changes: 0 additions & 95 deletions core-api/src/main/java/com/absmartly/sdk/ABSmartly.java

This file was deleted.

177 changes: 177 additions & 0 deletions core-api/src/main/java/com/absmartly/sdk/ABsmartly.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package com.absmartly.sdk;

import java.io.Closeable;
import java.io.IOException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java8.util.concurrent.CompletableFuture;

import javax.annotation.Nonnull;

import com.absmartly.sdk.java.time.Clock;
import com.absmartly.sdk.json.ContextData;

public class ABsmartly implements Closeable {
public static ABsmartly create(@Nonnull ABsmartlyConfig config) {
return new ABsmartly(config);
}

public static Builder builder() {
return new Builder();
}

public static class Builder {
private String endpoint;
private String apiKey;
private String application;
private String environment;
private ContextEventLogger eventLogger;

Builder() {}

public Builder endpoint(@Nonnull String endpoint) {
this.endpoint = endpoint;
return this;
}

public Builder apiKey(@Nonnull String apiKey) {
this.apiKey = apiKey;
return this;
}

public Builder application(@Nonnull String application) {
this.application = application;
return this;
}

public Builder environment(@Nonnull String environment) {
this.environment = environment;
return this;
}

public Builder eventLogger(@Nonnull ContextEventLogger eventLogger) {
this.eventLogger = eventLogger;
return this;
}

public ABsmartly build() {
if (endpoint == null) throw new IllegalArgumentException("endpoint is required");
if (apiKey == null) throw new IllegalArgumentException("apiKey is required");
if (application == null) throw new IllegalArgumentException("application is required");
if (environment == null) throw new IllegalArgumentException("environment is required");

final ClientConfig clientConfig = ClientConfig.create()
.setEndpoint(endpoint)
.setAPIKey(apiKey)
.setApplication(application)
.setEnvironment(environment);

final ABsmartlyConfig config = ABsmartlyConfig.create()
.setClient(Client.create(clientConfig));

if (eventLogger != null) {
config.setContextEventLogger(eventLogger);
}

return create(config);
}
}

protected ABsmartly(@Nonnull ABsmartlyConfig config) {
contextDataProvider_ = config.getContextDataProvider();
contextEventHandler_ = config.getContextPublisher();
contextEventLogger_ = config.getContextEventLogger();
variableParser_ = config.getVariableParser();
audienceDeserializer_ = config.getAudienceDeserializer();
scheduler_ = config.getScheduler();

if ((contextDataProvider_ == null) || (contextEventHandler_ == null)) {
client_ = config.getClient();
if (client_ == null) {
throw new IllegalArgumentException("Missing Client instance");
}

if (contextDataProvider_ == null) {
contextDataProvider_ = new DefaultContextDataProvider(client_);
}

if (contextEventHandler_ == null) {
contextEventHandler_ = new DefaultContextPublisher(client_);
}
}

if (variableParser_ == null) {
variableParser_ = new DefaultVariableParser();
}

if (audienceDeserializer_ == null) {
audienceDeserializer_ = new DefaultAudienceDeserializer();
}

if (scheduler_ == null) {
scheduler_ = new ScheduledThreadPoolExecutor(1);
}
}

public Context createContext(@Nonnull ContextConfig config) {
checkNotClosed();
return Context.create(Clock.systemUTC(), config, scheduler_, contextDataProvider_.getContextData(),
contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_,
new AudienceMatcher(audienceDeserializer_));
}

public Context createContextWith(@Nonnull ContextConfig config, ContextData data) {
checkNotClosed();
return Context.create(Clock.systemUTC(), config, scheduler_, CompletableFuture.completedFuture(data),
contextDataProvider_, contextEventHandler_, contextEventLogger_, variableParser_,
new AudienceMatcher(audienceDeserializer_));
}

public CompletableFuture<ContextData> getContextData() {
checkNotClosed();
return contextDataProvider_.getContextData();
}

private void checkNotClosed() {
if (closed_) {
throw new IllegalStateException("ABsmartly instance is closed");
}
}

@Override
public void close() throws IOException {
if (closed_) {
return;
}
closed_ = true;

Comment on lines +144 to +148
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

wc -l core-api/src/main/java/com/absmartly/sdk/ABsmartly.java

Repository: absmartly/java-sdk

Length of output: 121


🏁 Script executed:

sed -n '140,175p' core-api/src/main/java/com/absmartly/sdk/ABsmartly.java

Repository: absmartly/java-sdk

Length of output: 863


🏁 Script executed:

grep -n "private.*closed_" core-api/src/main/java/com/absmartly/sdk/ABsmartly.java

Repository: absmartly/java-sdk

Length of output: 100


🏁 Script executed:

grep -n "synchronized\|Lock\|AtomicBoolean\|volatile" core-api/src/main/java/com/absmartly/sdk/ABsmartly.java

Repository: absmartly/java-sdk

Length of output: 100


🏁 Script executed:

grep -n "void close\|public.*close" core-api/src/main/java/com/absmartly/sdk/ABsmartly.java

Repository: absmartly/java-sdk

Length of output: 107


Use AtomicBoolean to prevent concurrent double-close.

The check-then-set sequence on lines 144-148 is not atomic; two threads entering close() concurrently will both proceed past the guard, leading to duplicate calls to client_.close() and scheduler_.shutdown().

Suggested fix
+import java.util.concurrent.atomic.AtomicBoolean;
 
 `@Override`
 public void close() throws IOException {
-	if (closed_) {
+	if (!closed_.compareAndSet(false, true)) {
 		return;
 	}
-	closed_ = true;
 
 	if (client_ != null) {
 		client_.close();
 	}
 
 	if (scheduler_ != null) {
 		scheduler_.shutdown();
 		try {
 			if (!scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS)) {
 				scheduler_.shutdownNow();
 			}
 		} catch (InterruptedException e) {
 			Thread.currentThread().interrupt();
 			scheduler_.shutdownNow();
 		}
 	}
 }
 
-private volatile boolean closed_;
+private final AtomicBoolean closed_ = new AtomicBoolean(false);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core-api/src/main/java/com/absmartly/sdk/ABsmartly.java` around lines 144 -
148, Replace the non-atomic check-then-set on the boolean closed_ in the close()
method with an AtomicBoolean to avoid concurrent double-close: change closed_ to
an AtomicBoolean (e.g., AtomicBoolean closed_) and in close() use a
compareAndSet(false, true) (or getAndSet) to atomically guard the shutdown block
so that only the thread that successfully flips the flag calls client_.close()
and scheduler_.shutdown(); leave the rest of close() logic unchanged.

try {
if (client_ != null) {
client_.close();
}
} finally {
if (scheduler_ != null) {
scheduler_.shutdown();
try {
if (!scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS)) {
scheduler_.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
scheduler_.shutdownNow();
}
}
}
}

private volatile boolean closed_;
private Client client_;
private ContextDataProvider contextDataProvider_;
private ContextPublisher contextEventHandler_;
private ContextEventLogger contextEventLogger_;
private VariableParser variableParser_;

private AudienceDeserializer audienceDeserializer_;
private ScheduledExecutorService scheduler_;
}
Loading
Loading