Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ android {
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
compileSdk 32
compileSdk 34
minSdk 21
consumerProguardFiles("configcat-proguard-rules.pro")
}
Expand Down Expand Up @@ -72,7 +72,7 @@ dependencies {
testImplementation(libs.logback.classic)
testImplementation(libs.logback.core)
testImplementation(libs.mockwebserver)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.inline)
testRuntimeOnly(libs.junit.jupiter.engine)
}

Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
logback-core = { module = "ch.qos.logback:logback-core", version.ref = "logback" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockwebserver" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito" }
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit-jupiter" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit-jupiter" }
android-retrofuture = { module = "net.sourceforge.streamsupport:android-retrofuture", version.ref = "android-retrofuture" }
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/com/configcat/AppStateMonitor.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import android.content.res.Configuration;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.Build;
import android.os.Bundle;
import java9.util.function.Consumer;

Expand Down Expand Up @@ -40,7 +41,11 @@ public AppStateMonitor(Context context, ConfigCatLogger logger) {
application.registerComponentCallbacks(this);

IntentFilter filter = new IntentFilter(CONNECTIVITY_CHANGE);
application.registerReceiver(this, filter);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
application.registerReceiver(this, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
application.registerReceiver(this, filter);
}
}

public void addStateChangeListener(Consumer<Boolean> listener) {
Expand Down
41 changes: 26 additions & 15 deletions src/main/java/com/configcat/ConfigCatClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

private ConfigService configService;

private ConfigCatClient(String sdkKey, Options options) throws IllegalArgumentException {
private ConfigCatClient(String sdkKey, Options options) throws IllegalArgumentException, IOException {

Check failure on line 32 in src/main/java/com/configcat/ConfigCatClient.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=configcat_android-sdk&issues=AZ56nmzu_mu6a-CMcKAf&open=AZ56nmzu_mu6a-CMcKAf&pullRequest=78
this.logger = new ConfigCatLogger(LoggerFactory.getLogger(ConfigCatClient.class), options.logLevel, options.hooks, options.logFilter);
this.clientLogLevel = options.logLevel;

Expand All @@ -41,19 +41,26 @@
this.rolloutEvaluator = new RolloutEvaluator(this.logger);

if (this.overrideBehaviour != OverrideBehaviour.LOCAL_ONLY) {
ConfigFetcher fetcher = new ConfigFetcher(options.httpOptions,
this.logger,
sdkKey,
!options.isBaseURLCustom()
? options.dataGovernance == DataGovernance.GLOBAL
? BASE_URL_GLOBAL
: BASE_URL_EU
: options.baseUrl,
options.isBaseURLCustom(),
options.pollingMode.getPollingIdentifier());

StateMonitor monitor = options.context != null ? new AppStateMonitor(options.context, logger) : null;
this.configService = new ConfigService(sdkKey, monitor, options.pollingMode, options.cache, logger, fetcher, options.hooks, options.offline);
ConfigFetcher fetcher = null;
StateMonitor monitor = null;
try {
fetcher = new ConfigFetcher(options.httpOptions,
this.logger,
sdkKey,
!options.isBaseURLCustom()
? options.dataGovernance == DataGovernance.GLOBAL
? BASE_URL_GLOBAL
: BASE_URL_EU
: options.baseUrl,
options.isBaseURLCustom(),
options.pollingMode.getPollingIdentifier());
monitor = options.context != null ? new AppStateMonitor(options.context, logger) : null;
this.configService = new ConfigService(sdkKey, monitor, options.pollingMode, options.cache, logger, fetcher, options.hooks, options.offline);
} catch (Exception e) {
if(fetcher != null) fetcher.close();
if(monitor != null) monitor.close();
throw e;
}
} else {
this.hooks.invokeOnClientReady(ClientCacheState.HAS_LOCAL_OVERRIDE_FLAG_DATA_ONLY);
}
Expand Down Expand Up @@ -625,7 +632,11 @@
return client;
}

client = new ConfigCatClient(sdkKey, clientOptions);
try {
client = new ConfigCatClient(sdkKey, clientOptions);
} catch (IOException e) {
throw new RuntimeException("ConfigCatClient initialization failed.", e);

Check warning on line 638 in src/main/java/com/configcat/ConfigCatClient.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace generic exceptions with specific library exceptions or a custom exception.

See more on https://sonarcloud.io/project/issues?id=configcat_android-sdk&issues=AZ54R1T3feNRQCeuo0AA&open=AZ54R1T3feNRQCeuo0AA&pullRequest=78
}
INSTANCES.put(sdkKey, client);
return client;
}
Expand Down
22 changes: 19 additions & 3 deletions src/main/java/com/configcat/ConfigCatLogMessages.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.configcat;

import java.util.Iterator;
import java.util.Set;


Expand All @@ -26,7 +25,7 @@ final class ConfigCatLogMessages {
/**
* Log message for Fetch Failed Due To Unexpected error. The log eventId is 1103.
*/
public static final String FETCH_FAILED_DUE_TO_UNEXPECTED_ERROR = "Unexpected error occurred while trying to fetch config JSON. It is most likely due to a local network issue. Please make sure your application can reach the ConfigCat CDN servers (or your proxy server) over HTTP.";
private static final String FETCH_FAILED_DUE_TO_UNEXPECTED_ERROR = "Unexpected error occurred while trying to fetch config JSON. It is most likely due to a local network issue. Please make sure your application can reach the ConfigCat CDN servers (or your proxy server) over HTTP.";

/**
* Log message for Fetch Failed Due To Invalid Sdk Key error. The log eventId is 1100.
Expand Down Expand Up @@ -167,12 +166,29 @@ public static FormattableLogMessage getFetchFailedDueToUnexpectedHttpResponse(fi
*
* @param connectTimeoutMillis Connect timeout in milliseconds.
* @param readTimeoutMillis Read timeout in milliseconds.
* @param cfRayId The http response CF-RAY header value.
* @return The formattable log message.
*/
public static FormattableLogMessage getFetchFailedDueToRequestTimeout(final Integer connectTimeoutMillis, final Integer readTimeoutMillis) {
public static FormattableLogMessage getFetchFailedDueToRequestTimeout(final Integer connectTimeoutMillis, final Integer readTimeoutMillis, final String cfRayId) {
if (cfRayId != null) {
return new FormattableLogMessage("Request timed out while trying to fetch config JSON. Timeout values: [connect: %dms, read: %dms] %s", connectTimeoutMillis, readTimeoutMillis, ConfigCatLogMessages.getCFRayIdPostFix(cfRayId));
}
return new FormattableLogMessage("Request timed out while trying to fetch config JSON. Timeout values: [connect: %dms, read: %dms]", connectTimeoutMillis, readTimeoutMillis);
}

/**
* Log message for Fetch Failed Due To Unexpected error. The log eventId is 1103.
*
* @param cfRayId The http response CF-RAY header value.
* @return The formattable log message.
*/
public static FormattableLogMessage getFetchFailedDueToUnexpectedError(final String cfRayId) {
if (cfRayId != null) {
return new FormattableLogMessage(FETCH_FAILED_DUE_TO_UNEXPECTED_ERROR + " %s", ConfigCatLogMessages.getCFRayIdPostFix(cfRayId));
}
return new FormattableLogMessage(FETCH_FAILED_DUE_TO_UNEXPECTED_ERROR);
}

/**
* Log message for Fetch Failed Due To Redirect Loop error. The log eventId is 1104.
*
Expand Down
33 changes: 19 additions & 14 deletions src/main/java/com/configcat/ConfigFetcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ private CompletableFuture<FetchResponse> executeFetchAsync(int executionCount, S
}

} catch (Exception exception) {
this.logger.error(1103, ConfigCatLogMessages.FETCH_FAILED_DUE_TO_UNEXPECTED_ERROR, exception);
this.logger.error(1103, ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(fetchResponse.cfRayId()), exception);
return CompletableFuture.completedFuture(fetchResponse);
}

Expand All @@ -165,7 +165,8 @@ private CompletableFuture<FetchResponse> getResponseAsync(String eTag) {
private void callHTTP(String previousETag, CompletableFuture<FetchResponse> result) {
String requestUrl = this.url + "/configuration-files/" + this.sdkKey + "/" + Constants.CONFIG_JSON_NAME;
HttpURLConnection urlConnection = null;

String cfRayId = null;
FetchResponse fetchResponse = null;
try {
URL fetchUrl = new URL(requestUrl);
if (httpOptions.getProxy() != null) {
Expand All @@ -185,43 +186,47 @@ private void callHTTP(String previousETag, CompletableFuture<FetchResponse> resu
int responseCode = urlConnection.getResponseCode();
Map<String, List<String>> responseHeaders = urlConnection.getHeaderFields();

String cfRayId = readHeaderValue(responseHeaders, "CF-RAY");
cfRayId = readHeaderValue(responseHeaders, "CF-RAY");
if (responseCode == 200) {
String content = readBody(urlConnection.getInputStream());
String eTag = readHeaderValue(responseHeaders,"ETag");
Result<Config> configResult = deserializeConfig(content, cfRayId);
if (configResult.error() != null) {
result.complete(FetchResponse.failed(configResult.error(), false, cfRayId));
return;
fetchResponse = FetchResponse.failed(configResult.error(), false, cfRayId);
} else {
logger.debug("Fetch was successful: new config fetched.");
fetchResponse = FetchResponse.fetched(new Entry(configResult.value(), eTag, content, System.currentTimeMillis()), cfRayId);
}
logger.debug("Fetch was successful: new config fetched.");
result.complete(FetchResponse.fetched(new Entry(configResult.value(), eTag, content, System.currentTimeMillis()), cfRayId));
} else if (responseCode == 304) {
if(cfRayId != null) {
logger.debug(String.format("Fetch was successful: config not modified. %s", ConfigCatLogMessages.getCFRayIdPostFix(cfRayId)));
} else {
logger.debug("Fetch was successful: config not modified.");
}
result.complete(FetchResponse.notModified(cfRayId));
fetchResponse = FetchResponse.notModified(cfRayId);
} else if (responseCode == 403 || responseCode == 404) {
FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToInvalidSDKKey(cfRayId);
logger.error(1100, message);
result.complete(FetchResponse.failed(message, true, cfRayId));
fetchResponse = FetchResponse.failed(message, true, cfRayId);
} else {
FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToUnexpectedHttpResponse(responseCode, urlConnection.getResponseMessage(), cfRayId);
logger.error(1101, message);
result.complete(FetchResponse.failed(message, false, cfRayId));
fetchResponse = FetchResponse.failed(message, false, cfRayId);
}

} catch (SocketTimeoutException e) {
FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToRequestTimeout(httpOptions.getConnectTimeoutMillis(), httpOptions.getReadTimeoutMillis());
FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToRequestTimeout(httpOptions.getConnectTimeoutMillis(), httpOptions.getReadTimeoutMillis(), cfRayId);
logger.error(1102, message, e);
result.complete(FetchResponse.failed(message, false, null));
fetchResponse = FetchResponse.failed(message, false, cfRayId);
} catch (Exception e) {
String message = ConfigCatLogMessages.FETCH_FAILED_DUE_TO_UNEXPECTED_ERROR;
FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId);
logger.error(1103, message, e);
result.complete(FetchResponse.failed(message + " " + e.getMessage(), false, null));
fetchResponse = FetchResponse.failed(message + " " + e.getMessage(), false, cfRayId);
Copy link
Copy Markdown
Contributor

@adams85 adams85 Jun 1, 2026

Choose a reason for hiding this comment

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

There's a minor inconsistency here compared to the Java SDK, which doesn't append e.getMessage() to the message returned by getFetchFailedDueToUnexpectedError.

I'm not sure which approach is the better.

The best solution is to store the exception in the FetchResponse object and expose it to the user via RefreshResult. But this is the subject of another ticket.

So we can leave this as is for now, I guess.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I leave it as it is for now, then.

} finally {
if(fetchResponse == null) {
fetchResponse = FetchResponse.failed(ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId), false, cfRayId);
}
result.complete(fetchResponse);
if (urlConnection != null) {
urlConnection.disconnect();
}
Expand Down
55 changes: 55 additions & 0 deletions src/test/java/com/configcat/ConfigCatClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import android.content.Context;
import org.mockito.MockedConstruction;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
Expand All @@ -19,6 +22,7 @@
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class ConfigCatClientTest {

Expand Down Expand Up @@ -1015,4 +1019,55 @@ void testWaitForReady() throws IOException, InterruptedException, ExecutionExcep
server.shutdown();
cl.close();
}

@Test
void fetcherClosedWhenAppStateMonitorInitFails() throws IOException {
Context mockContext = mock(Context.class);
when(mockContext.getApplicationContext()).thenThrow(new RuntimeException("AppStateMonitor init failure"));

try (MockedConstruction<ConfigFetcher> fetcherConstruction = mockConstruction(ConfigFetcher.class)) {
RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> ConfigCatClient.get(Helpers.SDK_KEY, options -> {
options.pollingMode(PollingModes.manualPoll());
options.watchAppStateChanges(mockContext);
}));
assertEquals("AppStateMonitor init failure", runtimeException.getMessage());

assertEquals(1, fetcherConstruction.constructed().size());
ConfigFetcher constructedFetcher = fetcherConstruction.constructed().get(0);
verify(constructedFetcher).close();
}

ConfigCatClient.closeAll();
}

@Test
void fetcherAndMonitorClosedWhenConfigServiceInitFails() throws IOException {
Context mockContext = mock(Context.class);

try (MockedConstruction<ConfigFetcher> fetcherConstruction = mockConstruction(ConfigFetcher.class);
MockedConstruction<AppStateMonitor> monitorConstruction = mockConstruction(AppStateMonitor.class)) {

RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> ConfigCatClient.get(Helpers.SDK_KEY, options -> {
options.pollingMode(PollingModes.manualPoll());
options.watchAppStateChanges(mockContext);
// Adding a hook listener that throws causes ConfigService constructor to fail
// during setInitialized() -> hooks.invokeOnClientReady()
options.hooks().addOnClientReady(state -> {
throw new RuntimeException("ConfigService init failure");
});
}));
assertEquals("ConfigService init failure", runtimeException.getMessage());

assertEquals(1, fetcherConstruction.constructed().size());
ConfigFetcher constructedFetcher = fetcherConstruction.constructed().get(0);
verify(constructedFetcher).close();

assertEquals(1, monitorConstruction.constructed().size());
AppStateMonitor constructedMonitor = monitorConstruction.constructed().get(0);
verify(constructedMonitor).close();
}

ConfigCatClient.closeAll();
}

}
Loading
Loading