Skip to content
Open
Show file tree
Hide file tree
Changes from 16 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
Copy Markdown

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
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,73 @@
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 class ABsmartly implements Closeable {
public static ABsmartly create(@Nonnull ABsmartlyConfig config) {
return new ABsmartly(config);
}

private ABSmartly(@Nonnull ABSmartlyConfig 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.getContextEventHandler();
contextEventLogger_ = config.getContextEventLogger();
Expand Down Expand Up @@ -54,36 +115,55 @@ private ABSmartly(@Nonnull ABSmartlyConfig config) {
}

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
Copy Markdown

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.

if (client_ != null) {
client_.close();
client_ = null;
}

if (scheduler_ != null) {
scheduler_.shutdown();
try {
scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS);
} catch (InterruptedException ignored) {}
scheduler_ = null;
if (!scheduler_.awaitTermination(5000, TimeUnit.MILLISECONDS)) {
scheduler_.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
scheduler_.shutdownNow();
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

private volatile boolean closed_;
private Client client_;
private ContextDataProvider contextDataProvider_;
private ContextEventHandler contextEventHandler_;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@

import javax.annotation.Nonnull;

public class ABSmartlyConfig {
public static ABSmartlyConfig create() {
return new ABSmartlyConfig();
public class ABsmartlyConfig {
public static ABsmartlyConfig create() {
return new ABsmartlyConfig();
}

private ABSmartlyConfig() {}
protected ABsmartlyConfig() {}

public ContextDataProvider getContextDataProvider() {
return contextDataProvider_;
}

public ABSmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider contextDataProvider) {
public ABsmartlyConfig setContextDataProvider(@Nonnull final ContextDataProvider contextDataProvider) {
contextDataProvider_ = contextDataProvider;
return this;
}
Expand All @@ -24,7 +24,7 @@ public ContextEventHandler getContextEventHandler() {
return contextEventHandler_;
}

public ABSmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) {
public ABsmartlyConfig setContextEventHandler(@Nonnull final ContextEventHandler contextEventHandler) {
contextEventHandler_ = contextEventHandler;
return this;
}
Expand All @@ -33,7 +33,7 @@ public VariableParser getVariableParser() {
return variableParser_;
}

public ABSmartlyConfig setVariableParser(@Nonnull final VariableParser variableParser) {
public ABsmartlyConfig setVariableParser(@Nonnull final VariableParser variableParser) {
variableParser_ = variableParser;
return this;
}
Expand All @@ -42,7 +42,7 @@ public ScheduledExecutorService getScheduler() {
return scheduler_;
}

public ABSmartlyConfig setScheduler(@Nonnull final ScheduledExecutorService scheduler) {
public ABsmartlyConfig setScheduler(@Nonnull final ScheduledExecutorService scheduler) {
scheduler_ = scheduler;
return this;
}
Expand All @@ -51,7 +51,7 @@ public ContextEventLogger getContextEventLogger() {
return contextEventLogger_;
}

public ABSmartlyConfig setContextEventLogger(@Nonnull final ContextEventLogger logger) {
public ABsmartlyConfig setContextEventLogger(@Nonnull final ContextEventLogger logger) {
contextEventLogger_ = logger;
return this;
}
Expand All @@ -60,7 +60,7 @@ public AudienceDeserializer getAudienceDeserializer() {
return audienceDeserializer_;
}

public ABSmartlyConfig setAudienceDeserializer(@Nonnull final AudienceDeserializer audienceDeserializer) {
public ABsmartlyConfig setAudienceDeserializer(@Nonnull final AudienceDeserializer audienceDeserializer) {
audienceDeserializer_ = audienceDeserializer;
return this;
}
Expand All @@ -69,7 +69,7 @@ public Client getClient() {
return client_;
}

public ABSmartlyConfig setClient(Client client) {
public ABsmartlyConfig setClient(Client client) {
client_ = client;
return this;
}
Expand Down
3 changes: 3 additions & 0 deletions core-api/src/main/java/com/absmartly/sdk/AudienceMatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public boolean get() {
}

public Result evaluate(String audience, Map<String, Object> attributes) {
if (audience == null || audience.isEmpty()) {
return null;
}
final byte[] bytes = audience.getBytes(StandardCharsets.UTF_8);
final Map<String, Object> audienceMap = deserializer_.deserialize(bytes, 0, bytes.length);
if (audienceMap != null) {
Expand Down
30 changes: 27 additions & 3 deletions core-api/src/main/java/com/absmartly/sdk/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@

import javax.annotation.Nonnull;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.absmartly.sdk.json.ContextData;
import com.absmartly.sdk.json.PublishEvent;

public class Client implements Closeable {
private static final Logger log = LoggerFactory.getLogger(Client.class);
static public Client create(@Nonnull final ClientConfig config) {
return new Client(config, DefaultHTTPClient.create(DefaultHTTPClientConfig.create()));
}
Expand All @@ -31,6 +35,15 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H
throw new IllegalArgumentException("Missing Endpoint configuration");
}

if (!endpoint.startsWith("https://")) {
if (endpoint.startsWith("http://")) {
log.warn("ABsmartly SDK endpoint is not using HTTPS. API keys will be transmitted in plaintext: {}",
endpoint);
} else {
throw new IllegalArgumentException("Endpoint must use http:// or https:// protocol: " + endpoint);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

final String apiKey = config.getAPIKey();
if ((apiKey == null) || apiKey.isEmpty()) {
throw new IllegalArgumentException("Missing APIKey configuration");
Expand All @@ -46,7 +59,8 @@ static public Client create(@Nonnull final ClientConfig config, @Nonnull final H
throw new IllegalArgumentException("Missing Environment configuration");
}

url_ = endpoint + "/context";
final String normalizedEndpoint = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint;
url_ = normalizedEndpoint + "/context";
httpClient_ = httpClient;
deserializer_ = config.getContextDataDeserializer();
serializer_ = config.getContextEventSerializer();
Expand Down Expand Up @@ -86,8 +100,18 @@ public void accept(HTTPClient.Response response) {
final int code = response.getStatusCode();
if ((code / 100) == 2) {
final byte[] content = response.getContent();
dataFuture.complete(
deserializer_.deserialize(response.getContent(), 0, content.length));
if (content == null || content.length == 0) {
dataFuture.completeExceptionally(new IllegalStateException(
"Empty response body from context data endpoint"));
} else {
final ContextData result = deserializer_.deserialize(content, 0, content.length);
if (result != null) {
dataFuture.complete(result);
} else {
dataFuture.completeExceptionally(new IllegalStateException(
"Failed to deserialize context data response"));
}
}
} else {
dataFuture.completeExceptionally(new Exception(response.getStatusMessage()));
}
Expand Down
Loading
Loading