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 11a83338..30cb05cf 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 @@ -245,6 +245,20 @@ public class Config { protected boolean locationEnabled = true; protected boolean autoSendUserProperties = true; + /** + * Seed value for the SDK behavior settings. Used on first init only, + * when no settings have yet been persisted; subsequent inits use the + * value pulled from storage. + */ + protected String sdkBehaviorSettings = null; + + /** + * If {@code true}, the SDK skips the periodic {@code /o/sdk?method=sc} + * fetch. The seed JSON from {@link #setSdkBehaviorSettings(String)} (or + * a previously-persisted snapshot) is still applied. + */ + protected boolean sdkBehaviorSettingsRequestsDisabled = false; + // TODO: storage limits & configuration // protected int maxRequestsStored = 0; // protected int storageDirectory = ""; @@ -1497,4 +1511,29 @@ public Config disableAutoSendUserProperties() { this.autoSendUserProperties = false; return this; } + + /** + * Provide an initial SDK behavior settings payload to apply at init time, + * before the first {@code /o/sdk?method=sc} fetch returns. The value is + * the same JSON shape the Countly server emits: {@code {"v":1,"t":, + * "c":{...}}}. The seed is only used when nothing has been persisted yet. + * + * @param sdkBehaviorSettings serialized config JSON, or {@code null} for none + * @return {@code this} instance for method chaining + */ + public Config setSdkBehaviorSettings(String sdkBehaviorSettings) { + this.sdkBehaviorSettings = sdkBehaviorSettings; + return this; + } + + /** + * Disable the periodic SDK behavior settings fetch from the server. Any + * seed JSON or previously-persisted snapshot is still applied at init. + * + * @return {@code this} instance for method chaining + */ + public Config disableSdkBehaviorSettingsUpdates() { + this.sdkBehaviorSettingsRequestsDisabled = true; + return this; + } } diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigurationProvider.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigurationProvider.java new file mode 100644 index 00000000..ded93f1e --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ConfigurationProvider.java @@ -0,0 +1,26 @@ +package ly.count.sdk.java.internal; + +/** + * Source of truth for the runtime feature toggles that come from + * the server-driven SDK behavior settings ({@code /o/sdk?method=sc}). + * + * The default implementation lives in {@link ModuleConfiguration}. When no + * behavior settings have been received, every getter returns {@code true} + * (the safe default) so the SDK behaves identically to a pre-feature build. + */ +public interface ConfigurationProvider { + + boolean getNetworkingEnabled(); + + boolean getTrackingEnabled(); + + boolean getSessionTrackingEnabled(); + + boolean getViewTrackingEnabled(); + + boolean getCustomEventTrackingEnabled(); + + boolean getCrashReportingEnabled(); + + boolean getLocationTrackingEnabled(); +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/CoreFeature.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/CoreFeature.java index 76aa0a4d..e07a7189 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/CoreFeature.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/CoreFeature.java @@ -21,7 +21,8 @@ public enum CoreFeature { DeviceId(1 << 20, ModuleDeviceIdCore::new), Requests(1 << 21, ModuleRequests::new), Logs(1 << 22), - Feedback(1 << 23, ModuleFeedback::new); + Feedback(1 << 23, ModuleFeedback::new), + Configuration(1 << 24, ModuleConfiguration::new); private final int index; diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/DefaultNetworking.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/DefaultNetworking.java index 085775c3..2fc498c8 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/DefaultNetworking.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/DefaultNetworking.java @@ -36,6 +36,10 @@ protected Tasks.Task submit(final InternalConfig config) { return new Tasks.Task(Tasks.ID_STRICT) { @Override public Boolean call() throws Exception { + if (!config.getNetworkingEnabled()) { + L.d("[Networking] submit, networking disabled by SDK behavior settings; skipping queue drain"); + return false; + } final Request request = storageForRequestQueue.getNextRequest(); if (request == null) { return false; 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 d446315d..f49396e9 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,12 @@ public class InternalConfig extends Config { protected IdGenerator viewIdGenerator; protected IdGenerator eventIdGenerator; protected ViewIdProvider viewIdProvider; + /** + * Set by {@link ModuleConfiguration} at construction time; remains + * {@code null} until that module runs, which means callers must + * tolerate that absence (treat it as "no overrides yet"). + */ + public ConfigurationProvider configProvider; /** * Shouldn't be used! @@ -144,13 +150,26 @@ public void setDefaultNetworking(boolean defaultNetworking) { } /** - * This feature is not yet implemented - * and always return true + * Whether the SDK is currently allowed to perform network requests. + * Defaults to {@code true}; the value is overridden when a SDK + * behavior settings payload from the server toggles {@code networking} + * off (see {@link ModuleConfiguration}). * - * @return true + * @return {@code true} if networking is allowed */ public boolean getNetworkingEnabled() { - return true; + if (configProvider == null) { + return true; + } + return configProvider.getNetworkingEnabled(); + } + + public String getSdkBehaviorSettings() { + return sdkBehaviorSettings; + } + + public boolean isSdkBehaviorSettingsRequestsDisabled() { + return sdkBehaviorSettingsRequestsDisabled; } /** diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleConfiguration.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleConfiguration.java new file mode 100644 index 00000000..09935976 --- /dev/null +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleConfiguration.java @@ -0,0 +1,401 @@ +package ly.count.sdk.java.internal; + +import java.util.Iterator; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Receives, validates and applies SDK behavior settings sourced from the + * Countly server's {@code /o/sdk?method=sc} endpoint. The settings let the + * server toggle individual feature trackings (events, sessions, views, + * crashes, location) and tune queue sizes / update intervals without + * shipping a new SDK build. + * + * The module is the canonical {@link ConfigurationProvider} for the SDK; + * other modules read flags through that interface. + */ +public class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { + + JSONObject latestRetrievedConfigurationFull = null; + JSONObject latestRetrievedConfiguration = null; + + private CountlyTimer serverConfigUpdateTimer; + private long lastServerConfigFetchTimestamp = -1; + private boolean serverConfigRequestsDisabled = false; + + // ---- response envelope keys ---- + static final String keyRTimestamp = "t"; + static final String keyRVersion = "v"; + static final String keyRConfig = "c"; + + // ---- inner config keys (mirrors Android subset that maps to Java's feature surface) ---- + static final String keyRTracking = "tracking"; + static final String keyRNetworking = "networking"; + static final String keyRSessionTracking = "st"; + static final String keyRCrashReporting = "crt"; + static final String keyRViewTracking = "vt"; + static final String keyRCustomEventTracking = "cet"; + static final String keyRLocationTracking = "lt"; + static final String keyRConsentRequired = "cr"; + static final String keyRLogging = "log"; + static final String keyRServerConfigUpdateInterval = "scui"; + static final String keyRReqQueueSize = "rqs"; + static final String keyREventQueueSize = "eqs"; + static final String keyRSessionUpdateInterval = "sui"; + + // ---- runtime flags (defaults: safe = "everything on") ---- + boolean currentVTracking = true; + boolean currentVNetworking = true; + boolean currentVSessionTracking = true; + boolean currentVCrashReporting = true; + boolean currentVViewTracking = true; + boolean currentVCustomEventTracking = true; + boolean currentVLocationTracking = true; + + // ---- timer tunable (hours between refresh fetches) ---- + int currentServerConfigUpdateInterval = 4; + + @Override + public void init(InternalConfig config) { + super.init(config); + L.v("[ModuleConfiguration] init"); + config.configProvider = this; + + serverConfigRequestsDisabled = config.isSdkBehaviorSettingsRequestsDisabled(); + serverConfigUpdateTimer = new CountlyTimer(L); + + loadConfigFromStorage(config.getSdkBehaviorSettings()); + updateConfigVariables(config); + } + + @Override + public void initFinished(@Nonnull InternalConfig config) { + L.d("[ModuleConfiguration] initFinished"); + if (!serverConfigRequestsDisabled) { + fetchConfigFromServer(config); + startServerConfigUpdateTimer(config); + } + } + + @Override + public void stop(InternalConfig config, boolean clear) { + super.stop(config, clear); + if (serverConfigUpdateTimer != null) { + serverConfigUpdateTimer.stopTimer(); + serverConfigUpdateTimer = null; + } + } + + /** + * Loads any previously-persisted config; falls back to the init-time + * seed JSON the developer passed via {@code Config.setSdkBehaviorSettings}. + */ + void loadConfigFromStorage(@Nullable String sdkBehaviorSettingsSeed) { + String sConfig = internalConfig.storageProvider.getServerConfig(); + + if (Utils.isEmptyOrNull(sConfig) && !Utils.isEmptyOrNull(sdkBehaviorSettingsSeed)) { + sConfig = sdkBehaviorSettingsSeed; + } + + L.v("[ModuleConfiguration] loadConfigFromStorage, [" + sConfig + "]"); + + if (Utils.isEmptyOrNull(sConfig)) { + L.d("[ModuleConfiguration] loadConfigFromStorage, nothing stored, defaults retained"); + return; + } + + try { + JSONObject parsed = new JSONObject(sConfig); + saveAndStoreDownloadedConfig(parsed); + } catch (JSONException e) { + L.w("[ModuleConfiguration] loadConfigFromStorage, failed to parse, " + e); + latestRetrievedConfigurationFull = null; + latestRetrievedConfiguration = null; + } + } + + /** + * Pulls a single value out of {@code latestRetrievedConfiguration}, + * applying type-coercion and an optional validator. If the key is + * missing or the value fails validation, the supplied current value + * is returned unchanged. + */ + private T extractValue(String key, StringBuilder sb, T currentValue, Class clazz, @Nullable ConfigurationValueValidator validator) { + if (latestRetrievedConfiguration == null || !latestRetrievedConfiguration.has(key)) { + return currentValue; + } + try { + Object value = latestRetrievedConfiguration.get(key); + if (value.equals(currentValue)) { + return currentValue; + } + T extractedValue = clazz.cast(value); + if (validator != null && !validator.validate(extractedValue)) { + L.w("[ModuleConfiguration] extractValue, value for '" + key + "' failed validation, value: [" + extractedValue + "]"); + return currentValue; + } + sb.append(key).append(":[").append(value).append("], "); + return extractedValue; + } catch (Exception e) { + L.w("[ModuleConfiguration] extractValue, failed to load '" + key + "', " + e.getMessage()); + return currentValue; + } + } + + private Boolean extractBool(String key, StringBuilder sb, Boolean currentValue) { + return extractValue(key, sb, currentValue, Boolean.class, null); + } + + /** + * Applies the freshly-validated config to runtime flags + writes any + * tunables back into {@link InternalConfig} so other modules pick them + * up. Tunable changes that drive timers (e.g. {@code sui}) only take + * effect at the next init — the SDK does not restart its global timer + * mid-session. + */ + private void updateConfigVariables(@Nonnull InternalConfig clyConfig) { + L.v("[ModuleConfiguration] updateConfigVariables"); + if (latestRetrievedConfiguration == null) { + return; + } + + StringBuilder sb = new StringBuilder(); + + currentVNetworking = extractBool(keyRNetworking, sb, currentVNetworking); + currentVTracking = extractBool(keyRTracking, sb, currentVTracking); + currentVSessionTracking = extractBool(keyRSessionTracking, sb, currentVSessionTracking); + currentVCrashReporting = extractBool(keyRCrashReporting, sb, currentVCrashReporting); + currentVViewTracking = extractBool(keyRViewTracking, sb, currentVViewTracking); + currentVCustomEventTracking = extractBool(keyRCustomEventTracking, sb, currentVCustomEventTracking); + currentVLocationTracking = extractBool(keyRLocationTracking, sb, currentVLocationTracking); + + currentServerConfigUpdateInterval = extractValue(keyRServerConfigUpdateInterval, sb, currentServerConfigUpdateInterval, Integer.class, value -> value > 0); + + Integer rqs = extractValue(keyRReqQueueSize, sb, clyConfig.getRequestQueueMaxSize(), Integer.class, value -> value > 0); + if (rqs != null && rqs != clyConfig.getRequestQueueMaxSize()) { + clyConfig.setRequestQueueMaxSize(rqs); + } + + Integer eqs = extractValue(keyREventQueueSize, sb, clyConfig.getEventsBufferSize(), Integer.class, value -> value > 0); + if (eqs != null && eqs != clyConfig.getEventsBufferSize()) { + clyConfig.setEventQueueSizeToSend(eqs); + } + + Integer sui = extractValue(keyRSessionUpdateInterval, sb, clyConfig.getSendUpdateEachSeconds(), Integer.class, value -> value > 0); + if (sui != null && sui != clyConfig.getSendUpdateEachSeconds()) { + clyConfig.setUpdateSessionTimerDelay(sui); + } + + // logging + consentRequired are recognized but applied at init only + extractBool(keyRLogging, sb, false); + extractBool(keyRConsentRequired, sb, clyConfig.requiresConsent()); + + String updatedValues = sb.toString(); + if (!updatedValues.isEmpty()) { + L.i("[ModuleConfiguration] updateConfigVariables, settings changed: [" + updatedValues + "]"); + } + } + + /** + * Validate the outer envelope. We require all three of {@code v}, {@code t}, + * {@code c} to be present, the envelope to have exactly those three keys + * (anything else is suspect), and {@code c} to be a non-empty object. + */ + boolean validateServerConfig(@Nonnull JSONObject config) { + L.v("[ModuleConfiguration] validateServerConfig"); + if (!config.has(keyRVersion)) { + L.w("[ModuleConfiguration] validateServerConfig, missing 'v'; rejected"); + return false; + } + if (!config.has(keyRTimestamp)) { + L.w("[ModuleConfiguration] validateServerConfig, missing 't'; rejected"); + return false; + } + if (!config.has(keyRConfig)) { + L.w("[ModuleConfiguration] validateServerConfig, missing 'c'; rejected"); + return false; + } + if (config.length() != 3) { + L.w("[ModuleConfiguration] validateServerConfig, wrong number of top-level keys; rejected"); + return false; + } + JSONObject inner = config.optJSONObject(keyRConfig); + if (inner == null || inner.length() == 0) { + L.d("[ModuleConfiguration] validateServerConfig, 'c' empty or not an object; rejected"); + return false; + } + removeUnsupportedKeys(inner); + return true; + } + + /** + * Drops keys whose values don't match the expected type, plus any key + * we don't know how to handle. Done in place; the resulting object is + * what we persist. + */ + private void removeUnsupportedKeys(@Nonnull JSONObject inner) { + Iterator keys = inner.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object value = inner.opt(key); + boolean isValid; + switch (key) { + case keyRNetworking: + case keyRTracking: + case keyRSessionTracking: + case keyRCrashReporting: + case keyRViewTracking: + case keyRCustomEventTracking: + case keyRLocationTracking: + case keyRConsentRequired: + case keyRLogging: + isValid = value instanceof Boolean; + break; + case keyRServerConfigUpdateInterval: + case keyRReqQueueSize: + case keyREventQueueSize: + case keyRSessionUpdateInterval: + isValid = value instanceof Integer && ((Integer) value) > 0; + break; + default: + isValid = false; + L.w("[ModuleConfiguration] removeUnsupportedKeys, unknown key '" + key + "', removed"); + break; + } + if (!isValid) { + L.w("[ModuleConfiguration] removeUnsupportedKeys, invalid value for '" + key + "': [" + value + "], removed"); + keys.remove(); + } + } + } + + /** + * Validate + merge a newly-received envelope into our cached state. + * The merge preserves keys from prior responses unless the new payload + * explicitly overwrites them; this matches Android's behavior. + */ + void saveAndStoreDownloadedConfig(@Nonnull JSONObject config) { + L.v("[ModuleConfiguration] saveAndStoreDownloadedConfig"); + if (!validateServerConfig(config)) { + L.w("[ModuleConfiguration] saveAndStoreDownloadedConfig, invalid envelope; ignored"); + latestRetrievedConfigurationFull = null; + latestRetrievedConfiguration = null; + return; + } + + JSONObject newInner = config.optJSONObject(keyRConfig); + if (latestRetrievedConfigurationFull == null) { + latestRetrievedConfigurationFull = new JSONObject(); + latestRetrievedConfiguration = new JSONObject(); + try { + latestRetrievedConfigurationFull.put(keyRConfig, latestRetrievedConfiguration); + } catch (JSONException ignored) { + } + } + + try { + latestRetrievedConfigurationFull.put(keyRTimestamp, config.get(keyRTimestamp)); + latestRetrievedConfigurationFull.put(keyRVersion, config.get(keyRVersion)); + } catch (JSONException e) { + L.w("[ModuleConfiguration] saveAndStoreDownloadedConfig, failed to merge version/timestamp; " + e); + } + + Iterator keys = newInner.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object value = newInner.opt(key); + if (value != null && !JSONObject.NULL.equals(value)) { + try { + latestRetrievedConfiguration.put(key, value); + } catch (JSONException e) { + L.w("[ModuleConfiguration] saveAndStoreDownloadedConfig, failed to merge inner key '" + key + "'; " + e); + } + } + } + + internalConfig.storageProvider.setServerConfig(latestRetrievedConfigurationFull.toString()); + } + + void fetchConfigFromServer(@Nonnull InternalConfig config) { + L.v("[ModuleConfiguration] fetchConfigFromServer"); + if (serverConfigRequestsDisabled) { + L.v("[ModuleConfiguration] fetchConfigFromServer, fetches disabled, aborting"); + return; + } + if (config.getDeviceId() == null) { + L.d("[ModuleConfiguration] fetchConfigFromServer, no device id yet, aborting"); + return; + } + + lastServerConfigFetchTimestamp = TimeUtils.timestampMs(); + + String requestData = ModuleRequests.prepareRequiredParams(config).add("method", "sc").toString(); + boolean networkingIsEnabled = config.getNetworkingEnabled(); + + if (config.sdk == null || config.sdk.networking == null) { + L.w("[ModuleConfiguration] fetchConfigFromServer, networking not ready, aborting"); + return; + } + + Transport transport = config.sdk.networking.getTransport(); + config.immediateRequestGenerator.createImmediateRequestMaker().doWork(requestData, "/o/sdk?", transport, false, networkingIsEnabled, response -> { + if (response == null) { + L.w("[ModuleConfiguration] fetchConfigFromServer, no response (offline or server unreachable)"); + return; + } + L.d("[ModuleConfiguration] fetchConfigFromServer, received: [" + response + "]"); + saveAndStoreDownloadedConfig(response); + updateConfigVariables(config); + }, L); + } + + private void startServerConfigUpdateTimer(@Nonnull InternalConfig config) { + long intervalSeconds = (long) currentServerConfigUpdateInterval * 60L * 60L; + serverConfigUpdateTimer.startTimer(intervalSeconds, () -> fetchConfigFromServer(config)); + } + + long getLastServerConfigFetchTimestamp() { + return lastServerConfigFetchTimestamp; + } + + @Override + public boolean getNetworkingEnabled() { + return currentVNetworking; + } + + @Override + public boolean getTrackingEnabled() { + return currentVTracking; + } + + @Override + public boolean getSessionTrackingEnabled() { + return currentVSessionTracking; + } + + @Override + public boolean getViewTrackingEnabled() { + return currentVViewTracking; + } + + @Override + public boolean getCustomEventTrackingEnabled() { + return currentVCustomEventTracking; + } + + @Override + public boolean getCrashReportingEnabled() { + return currentVCrashReporting; + } + + @Override + public boolean getLocationTrackingEnabled() { + return currentVLocationTracking; + } + + interface ConfigurationValueValidator { + boolean validate(T value); + } +} diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleCrashes.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleCrashes.java index a869a434..0c6f3250 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleCrashes.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleCrashes.java @@ -82,6 +82,10 @@ private void registerUncaughtExceptionHandler() { } protected void recordExceptionInternal(Throwable t, boolean handled, Map segments, String legacyCrashName) { + if (internalConfig.configProvider != null && (!internalConfig.configProvider.getTrackingEnabled() || !internalConfig.configProvider.getCrashReportingEnabled())) { + L.d("[ModuleCrash] recordExceptionInternal, crash reporting disabled by SDK behavior settings; ignoring"); + return; + } if (config.isBackendModeEnabled()) { L.w("[ModuleCrash] recordExceptionInternal, Skipping crash, backend mode is enabled!"); return; 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 1e48a875..67edd1da 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 @@ -95,6 +95,10 @@ private synchronized void addEventsToRequestQ(String deviceId) { } protected void recordEventInternal(String key, int count, Double sum, Double dur, Map segmentation, String eventIdOverride) { + if (internalConfig.configProvider != null && (!internalConfig.configProvider.getTrackingEnabled() || !internalConfig.configProvider.getCustomEventTrackingEnabled())) { + L.d("[ModuleEvents] recordEventInternal, event recording disabled by SDK behavior settings; ignoring"); + return; + } if (count <= 0) { L.w("[ModuleEvents] recordEventInternal, Count can't be less than 1, ignoring this event."); return; diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleLocation.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleLocation.java index c81b2adb..096949ee 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleLocation.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleLocation.java @@ -45,6 +45,10 @@ void sendLocation() { } void setLocationInternal(@Nullable String countryCode, @Nullable String cityName, @Nullable String gpsCoordinates, @Nullable String ipAddress) { + if (internalConfig.configProvider != null && (!internalConfig.configProvider.getTrackingEnabled() || !internalConfig.configProvider.getLocationTrackingEnabled())) { + L.d("[ModuleLocation] setLocationInternal, location tracking disabled by SDK behavior settings; ignoring"); + return; + } L.d("[ModuleLocation] setLocationInternal, Setting location parameters, cc[" + countryCode + "] cy[" + city + "] gps[" + gpsCoordinates + "] ip[" + ipAddress + "]"); if (countryCode != null ^ city != null) { diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleRequests.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleRequests.java index 5ea633c1..de4d1cd1 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleRequests.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleRequests.java @@ -206,6 +206,18 @@ public static Future pushAsync(InternalConfig config, Request request) public static Future pushAsync(final InternalConfig config, final Request request, final boolean noControl, final Tasks.Callback callback) { config.getLogger().d("New request " + request.storageId() + ": " + request); + if (config.configProvider != null && !config.configProvider.getTrackingEnabled()) { + config.getLogger().d("[ModuleRequests] pushAsync, tracking disabled by SDK behavior settings; dropping request"); + if (callback != null) { + try { + callback.call(null); + } catch (Exception e) { + config.getLogger().e("[ModuleRequests] Exception in a callback " + e); + } + } + return null; + } + if (!noControl && request.isEmpty()) { if (callback != null) { try { diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleSessions.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleSessions.java index 58977e5c..199cc9d1 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleSessions.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleSessions.java @@ -39,6 +39,10 @@ public void initFinished(final InternalConfig config) { @Override protected void onTimer() { + if (internalConfig.configProvider != null && (!internalConfig.configProvider.getTrackingEnabled() || !internalConfig.configProvider.getSessionTrackingEnabled())) { + L.d("[ModuleSessions] onTimer, session tracking disabled by SDK behavior settings; skipping update"); + return; + } if (!internalConfig.isBackendModeEnabled() && isActive() && getSession() != null) { L.i("[ModuleSessions] onTimer, updating session"); getSession().update(); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleViews.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleViews.java index dd176c3e..d9eb3b4a 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleViews.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleViews.java @@ -155,6 +155,10 @@ private void autoCloseRequiredViews(boolean closeAllViews, Map c * @return Returns link to Countly for call chaining */ @Nullable String startViewInternal(@Nullable String viewName, @Nullable Map customViewSegmentation, boolean viewShouldBeAutomaticallyStopped) { + if (internalConfig.configProvider != null && (!internalConfig.configProvider.getTrackingEnabled() || !internalConfig.configProvider.getViewTrackingEnabled())) { + L.d("[ModuleViews] startViewInternal, view tracking disabled by SDK behavior settings; ignoring"); + return null; + } if (viewName == null || viewName.isEmpty()) { L.e("[ModuleViews] startViewInternal, Trying to record view with null or empty view name, ignoring request"); 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 1a273436..63d371e2 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 @@ -73,6 +73,7 @@ protected static void registerDefaultModuleMappings() { moduleMappings.put(CoreFeature.RemoteConfig, ModuleRemoteConfig.class); moduleMappings.put(CoreFeature.UserProfiles, ModuleUserProfile.class); moduleMappings.put(CoreFeature.Location, ModuleLocation.class); + moduleMappings.put(CoreFeature.Configuration, ModuleConfiguration.class); } /** @@ -254,6 +255,9 @@ protected void buildModules(InternalConfig config, int features) throws IllegalA // standard required internal features modules.put(-3, new ModuleDeviceIdCore()); modules.put(-2, new ModuleRequests()); + // ModuleConfiguration is built unconditionally so the ConfigurationProvider + // is wired before other modules read flags through it. + modules.put(-1, new ModuleConfiguration()); modules.put(CoreFeature.Sessions.getIndex(), new ModuleSessions()); modules.put(CoreFeature.UserProfiles.getIndex(), new ModuleUserProfile()); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKStorage.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKStorage.java index 37e8bda1..ff907a57 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKStorage.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/SDKStorage.java @@ -33,6 +33,7 @@ public class SDKStorage implements StorageProvider { protected static final String key_device_id_type = "did_t"; protected static final String key_remote_config = "rc"; protected static final String key_migration_version = "dv"; + protected static final String key_server_config = "sc"; private JsonFileStorage jsonFileStorage; @@ -379,6 +380,20 @@ public void setMigrationVersion(Integer migrationVersion) { jsonFileStorage.addAndSave(key_migration_version, migrationVersion); } + @Override + public void setServerConfig(String config) { + if (config == null) { + jsonFileStorage.deleteAndSave(key_server_config); + } else { + jsonFileStorage.addAndSave(key_server_config, config); + } + } + + @Override + public String getServerConfig() { + return jsonFileStorage.getString(key_server_config); + } + @Override public boolean isCountlyStorageEmpty() { String[] files = getCountlyFileList(config); diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/StorageProvider.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/StorageProvider.java index 1983dd7a..da2d7bd4 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/StorageProvider.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/StorageProvider.java @@ -69,4 +69,18 @@ public interface StorageProvider { * @return true if empty, false otherwise */ boolean isCountlyStorageEmpty(); + + /** + * Persist the most-recently-validated SDK behavior settings JSON. + * + * @param config serialized configuration object, or {@code null} to clear + */ + void setServerConfig(String config); + + /** + * Read the persisted SDK behavior settings JSON. + * + * @return serialized configuration, or {@code null} if none persisted yet + */ + String getServerConfig(); } 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 eaaa4849..193c4432 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 @@ -61,6 +61,10 @@ static Config getBaseConfig(String deviceID) { checkSdkStorageRootDirectoryExist(sdkStorageRootDirectory); Config config = new Config(SERVER_URL, SERVER_APP_KEY, sdkStorageRootDirectory); config.setApplicationVersion(APPLICATION_VERSION); + // Disable server config requests by default in tests so the suite doesn't fire HTTP calls + // to a non-existent test host on every init. Tests covering ModuleConfiguration opt back in + // explicitly. + config.disableSdkBehaviorSettingsUpdates(); config.setCustomDeviceId(deviceID); return config;