From b61ef4ffddc9f8cb1e8cddcf6b960c51dd6e7626 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 21 Oct 2026 23:07:14 +0300 Subject: [PATCH 1/2] Keep the SDK starting over a store file cut short The store file was truncated and then written in place on every save, and the SDK saves it at every init and every settings response. A process stopped mid-write left a file that made every later Countly.init throw a JSONException into the application. Saves now write a temporary file next to the store and move it over the store, atomically where the file system allows. A store that is not valid JSON is logged and replaced by empty storage on the next save instead of failing init, and a temporary file left by an interrupted save is deleted when the storage opens, so it is not taken for other stored SDK data. --- .../sdk/java/internal/JsonFileStorage.java | 59 +++++++++++++++++-- .../java/internal/JsonFileStorageTests.java | 55 +++++++++++++++++ .../internal/ScenarioInitRecoveryTests.java | 14 +++++ 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/sdk-java/src/main/java/ly/count/sdk/java/internal/JsonFileStorage.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/JsonFileStorage.java index de2f26f9a..51d17cc54 100644 --- a/sdk-java/src/main/java/ly/count/sdk/java/internal/JsonFileStorage.java +++ b/sdk-java/src/main/java/ly/count/sdk/java/internal/JsonFileStorage.java @@ -3,9 +3,12 @@ import java.io.BufferedWriter; import java.io.File; import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import javax.annotation.Nonnull; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; public class JsonFileStorage { @@ -22,9 +25,22 @@ public class JsonFileStorage { public JsonFileStorage(@Nonnull final File file, @Nonnull Log logger) { this.logger = logger; this.file = file; + deleteLeftoverTemporaryFile(); this.json = readJsonFile(file); } + /** + * Deletes the temporary file of a save that was stopped before its move. The json file still + * holds the previous save then, and a leftover file would be taken for other stored SDK data. + */ + private void deleteLeftoverTemporaryFile() { + try { + Files.deleteIfExists(temporaryFileOf(file).toPath()); + } catch (IOException e) { + logger.w("[JsonFileStorage] deleteLeftoverTemporaryFile, Failed to delete the temporary file, reason: [" + e + "]"); + } + } + /** * If key exists changes its value, otherwise adds new key-value pair * Null key or value is not allowed @@ -39,26 +55,56 @@ public synchronized void add(@Nonnull final String key, @Nonnull Object value) { } /** - * Saves changes to the disk/db/memory. When the data cannot be serialized, the file keeps what - * it had. + * Saves changes to the disk/db/memory. The data is written next to the file and then moved over + * it, so a process stopped while saving leaves the previous file whole. When the data cannot be + * serialized, the file keeps what it had. */ public synchronized void save() { logger.i("[JsonFileStorage] save, Saving json file: [" + file.getAbsolutePath() + "]"); - // Serialized before the file is opened: opening truncates it, and toString returns null instead of throwing + // toString returns null instead of throwing, so a failed serialization has to be caught before anything is written String content = json.toString(); if (content == null) { logger.e("[JsonFileStorage] save, Failed to serialize the data, the json file is left as it is: [" + file.getAbsolutePath() + "]"); return; } - try (BufferedWriter writer = Files.newBufferedWriter(file.toPath())) { - writer.write(content); + File temporary = temporaryFileOf(file); + try { + try (BufferedWriter writer = Files.newBufferedWriter(temporary.toPath())) { + writer.write(content); + } + replaceFile(temporary, file); } catch (IOException e) { logger.e("[JsonFileStorage] save, Failed to save json file, reason: [" + e.getMessage() + "]"); } } + /** + * The file a save writes before moving it over the json file. + * + * @param file the json file + * @return the temporary file next to it + */ + static File temporaryFileOf(@Nonnull File file) { + return new File(file.getAbsolutePath() + ".tmp"); + } + + /** + * Moves a file over another, atomically where the file system supports it. + * + * @param source the file to move + * @param target the file it replaces + * @throws IOException when the move fails + */ + private static void replaceFile(@Nonnull File source, @Nonnull File target) throws IOException { + try { + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + /** * Removes key-value pair * Null key is not allowed @@ -225,6 +271,9 @@ private JSONObject readJsonFile(@Nonnull final File file) { } catch (IOException e) { logger.e("[JsonFileStorage] readJsonFile, Failed to read json file, reason: [" + e.getMessage() + "]"); return new JSONObject(); + } catch (JSONException e) { + logger.e("[JsonFileStorage] readJsonFile, The json file is not valid json, starting with empty storage that replaces it on the next save: [" + file.getAbsolutePath() + "], reason: [" + e.getMessage() + "]"); + return new JSONObject(); } } } diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/JsonFileStorageTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/JsonFileStorageTests.java index bf047fa15..09fccd1fe 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/JsonFileStorageTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/JsonFileStorageTests.java @@ -348,6 +348,61 @@ public java.io.Writer write(java.io.Writer writer, int indentFactor, int indent) Mockito.verify(logger).e(Mockito.startsWith("[JsonFileStorage] save, Failed to serialize the data")); } + /** + * A json file cut short, as a process stopped while writing leaves it, does not stop the storage. + *

+ * Verifies that the storage starts empty, that the failure is logged, and that the next save + * replaces the broken file with valid json. + */ + @Test + public void readJsonFile_brokenJson_startsEmptyAndTheNextSaveReplacesIt() { + Log logger = Mockito.mock(Log.class); + setupJsonFile("{\"did\":\"generated_uuid\",\"sc\":\"{\\\"c\\\":{"); + storage = new JsonFileStorage(jsonFile(), logger); + + Assert.assertEquals(0, storage.size()); + Mockito.verify(logger).e(Mockito.startsWith("[JsonFileStorage] readJsonFile, The json file is not valid json")); + + storage.addAndSave("key", "value"); + Assert.assertEquals("value", TestUtils.readJsonFile(jsonFile()).getString("key")); + } + + /** + * "save" writes next to the file and moves the result over it. + *

+ * Verifies that the file holds what was stored before and what was added, and that no temporary + * file is left behind. + */ + @Test + public void save_replacesTheFileWhole_andLeavesNoTemporaryFile() { + setupJsonFile("{\"old\":\"value\"}"); + storage = new JsonFileStorage(jsonFile(), L); + storage.addAndSave("new", "value"); + + JSONObject saved = TestUtils.readJsonFile(jsonFile()); + Assert.assertEquals("value", saved.getString("old")); + Assert.assertEquals("value", saved.getString("new")); + Assert.assertFalse(JsonFileStorage.temporaryFileOf(jsonFile()).exists()); + } + + /** + * The temporary file of a save stopped before its move is deleted when the storage opens. + *

+ * Verifies that the storage reads the json file, which holds the previous save, and that the + * leftover file is gone, so it is not taken for other stored SDK data. + */ + @Test + public void constructor_deletesTheTemporaryFileOfAnInterruptedSave() throws IOException { + setupJsonFile("{\"saved\":\"before\"}"); + File leftover = JsonFileStorage.temporaryFileOf(jsonFile()); + Files.write(leftover.toPath(), "{\"saved\":".getBytes()); + + storage = new JsonFileStorage(jsonFile(), L); + + Assert.assertEquals("before", storage.getString("saved")); + Assert.assertFalse(leftover.exists()); + } + private static File jsonFile() { return new File(TestUtils.getTestSDirectory(), JSON_FILE_NAME); } diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java index da006423f..619ae55e0 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ScenarioInitRecoveryTests.java @@ -197,6 +197,20 @@ public void init_withCrashAndSessionFiles() throws InterruptedException { // ── Corrupt/empty file resilience ─────────────────────────────────── + /** + * "init_withBrokenStoreFile" + * A store file cut short, as a process stopped while writing leaves it, must not stop the SDK + * from initializing: the storage starts empty instead of throwing to the application. + */ + @Test + public void init_withBrokenStoreFile() { + TestUtils.writeToFile(SDKStorage.JSON_FILE_NAME, "{\"did\":\"generated_uuid\",\"sc\":\"{\\\"c\\\":{"); + + Countly.instance().init(TestUtils.getBaseConfig()); + + Assert.assertTrue("SDK should be initialized even with a broken store file", Countly.isInitialized()); + } + /** * "init_withCorruptCrashFile" * Corrupt crash file should not block initialization. From 17cb726162bb5825cf7ade4109e652dd62ba413c Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 21 Oct 2026 23:07:15 +0300 Subject: [PATCH 2/2] Replace the stored SDK behavior settings with each answer Each response was merged into the stored settings, so a setting removed in SDK Manager kept its last server value for good, across restarts too: a removed kill switch, filter, journey trigger, limit or rqs stayed in force. Each valid response now replaces the stored settings, and a setting it leaves out goes back to the developer configuration or the SDK default, as the reference implementation does since its settings merge fix. Values the validation drops resolve the same way. A cr false from the server, from storage or from the provided settings no longer lifts the consent requirement the developer set: the resolved requirement is the developer's value or a cr true, so the settings can add the requirement but never remove it. While a device ID change without merge ends the previous device ID's timed events and view, a journey trigger or a full event queue reached on that thread no longer sends the queue under the new device ID; those events go out together under the previous one, and refresh no content zone. Tests whose later responses relied on the merge now carry the settings they need in each response. --- .../main/java/ly/count/sdk/java/Config.java | 6 +- .../java/internal/ModuleConfiguration.java | 233 +++++++----------- .../count/sdk/java/internal/ModuleEvents.java | 39 ++- .../ModuleConfigurationContentTests.java | 33 ++- .../ModuleConfigurationFiltersTests.java | 4 +- .../ModuleConfigurationLimitsTests.java | 6 +- .../ModuleConfigurationNetworkTests.java | 4 +- .../internal/ModuleConfigurationTests.java | 184 ++++++++++++-- 8 files changed, 313 insertions(+), 196 deletions(-) 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 852c5214a..5cf1f4c2d 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 @@ -1533,7 +1533,11 @@ public Config disableAutoSendUserProperties() { * 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. + * "c":{...}}}. The seed is only used when nothing has been persisted yet, + * and it stands in for the server's settings: the first answer from the + * server replaces it, so a setting the answer does not carry goes back to + * this configuration. A {@code "cr":true} here can be lifted by that answer; + * use {@link #setRequiresConsent(boolean)} for a requirement that must hold. * * @param sdkBehaviorSettings serialized config JSON, or {@code null} for none * @return {@code this} instance for method chaining 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 index e5e9f2f4c..c3ea88145 100644 --- 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 @@ -21,6 +21,8 @@ *

* Every setting starts from the developer configuration and is overridden by the settings provided * at init (only while nothing is stored), by the stored settings and by every later server response. + * Each accepted response replaces the stored settings as a whole, so a setting the server stops + * sending goes back to the developer configuration, or to the SDK default when it sets none. * {@link SDKCore} runs {@link #init(InternalConfig)}, which does the resolve, before it starts the * global timer and builds the other modules; {@link #initFinished(InternalConfig)} fetches the * settings and starts the refresh timer. In backend mode the module stays inert and serves the @@ -99,10 +101,12 @@ public class ModuleConfiguration extends ModuleBase implements ConfigurationProv static final int MIN_CONTENT_ZONE_INTERVAL = 16; static final int NO_LIMIT = Integer.MAX_VALUE; - //the settings merged from every accepted configuration, the stored form; guarded by this - JSONObject latestRetrievedConfigurationFull = null; + //the settings of the last accepted configuration; guarded by this JSONObject latestRetrievedConfiguration = null; + //what each setting goes back to when the stored settings do not carry it, read from the developer configuration at init + private ConfigurationProvider developerValues = null; + //guarded by this CountlyTimer serverConfigUpdateTimer = null; @@ -224,6 +228,7 @@ public void init(InternalConfig config) { L.d("[ModuleConfiguration] init, backend mode:[" + backendMode + "], server config requests disabled:[" + serverConfigRequestsDisabled + "]"); seedFromDeveloperConfig(config); + developerValues = developerDefaults(config); if (backendMode) { L.d("[ModuleConfiguration] init, backend mode is enabled, SDK behavior settings are not applied"); @@ -315,7 +320,7 @@ void loadConfigFromStorage(@Nullable String sdkBehaviorSettings) { * not support. Other top level keys are allowed; they are never stored. * * @param config a configuration as the server sends it, its settings object is changed in place - * @return whether the configuration can be merged + * @return whether the configuration can be stored */ boolean validateServerConfig(@Nonnull JSONObject config) { L.v("[ModuleConfiguration] validateServerConfig"); @@ -451,9 +456,10 @@ private static boolean isFraction(@Nullable Object value) { } /** - * Validates a configuration, merges its settings into the stored ones and stores the result. - * Only {@code v}, {@code t} and {@code c} are stored, so the log gathering directive and the - * connection test flag never are. An invalid configuration leaves the stored settings as they are. + * Validates a configuration and replaces the stored settings with its settings, so a setting it + * does not carry is no longer stored. Only {@code v}, {@code t} and {@code c} are stored, so the + * log gathering directive and the connection test flag never are. An invalid configuration + * leaves the stored settings as they are. * * @param config a configuration as the server sends it */ @@ -464,16 +470,6 @@ synchronized void saveAndStoreDownloadedConfig(@Nonnull JSONObject config) { return; } - JSONObject newInner = config.optJSONObject(keyRConfig); - if (latestRetrievedConfigurationFull == null) { - latestRetrievedConfiguration = new JSONObject(); - latestRetrievedConfigurationFull = new JSONObject(); - latestRetrievedConfigurationFull.put(keyRConfig, latestRetrievedConfiguration); - } - - latestRetrievedConfigurationFull.put(keyRVersion, config.get(keyRVersion)); - latestRetrievedConfigurationFull.put(keyRTimestamp, config.get(keyRTimestamp)); - List notStoredTopLevelKeys = new ArrayList<>(); for (String key : config.keySet()) { if (!keyRVersion.equals(key) && !keyRTimestamp.equals(key) && !keyRConfig.equals(key)) { @@ -484,48 +480,9 @@ synchronized void saveAndStoreDownloadedConfig(@Nonnull JSONObject config) { L.d("[ModuleConfiguration] saveAndStoreDownloadedConfig, top level keys that are not stored: " + notStoredTopLevelKeys); } - removeListingFilterKeysFromConfig(newInner); - - for (String key : newInner.keySet()) { - Object value = newInner.opt(key); - if (value != null && !JSONObject.NULL.equals(value)) { - latestRetrievedConfiguration.put(key, value); - } - } - - internalConfig.storageProvider.setServerConfig(latestRetrievedConfigurationFull.toString()); - } - - /** - * Drops the stored listing filters of the opposite kind: a configuration with any whitelist - * removes every stored blacklist and the reverse. One with neither keeps the stored filters. - * - * @param newConfig the settings object being merged - */ - private void removeListingFilterKeysFromConfig(@Nonnull JSONObject newConfig) { - boolean hasAnyWhitelist = newConfig.has(keyREventWhitelist) - || newConfig.has(keyRUserPropertyWhitelist) - || newConfig.has(keyRSegmentationWhitelist) - || newConfig.has(keyREventSegmentationWhitelist); - - boolean hasAnyBlacklist = newConfig.has(keyREventBlacklist) - || newConfig.has(keyRUserPropertyBlacklist) - || newConfig.has(keyRSegmentationBlacklist) - || newConfig.has(keyREventSegmentationBlacklist); - - if (hasAnyWhitelist) { - latestRetrievedConfiguration.remove(keyREventBlacklist); - latestRetrievedConfiguration.remove(keyRUserPropertyBlacklist); - latestRetrievedConfiguration.remove(keyRSegmentationBlacklist); - latestRetrievedConfiguration.remove(keyREventSegmentationBlacklist); - } - - if (hasAnyBlacklist) { - latestRetrievedConfiguration.remove(keyREventWhitelist); - latestRetrievedConfiguration.remove(keyRUserPropertyWhitelist); - latestRetrievedConfiguration.remove(keyRSegmentationWhitelist); - latestRetrievedConfiguration.remove(keyREventSegmentationWhitelist); - } + //validation already removed every value that is not a usable setting, so the settings object is kept as it is + latestRetrievedConfiguration = config.optJSONObject(keyRConfig); + internalConfig.storageProvider.setServerConfig(new JSONObject(config, keyRVersion, keyRTimestamp, keyRConfig).toString()); } /** @@ -548,40 +505,42 @@ synchronized boolean updateConfigVariables(@Nonnull InternalConfig config, @Null StringBuilder sb = new StringBuilder(); boolean previousRequiresConsent = currentVRequiresConsent; - - currentVNetworking = extractBoolean(keyRNetworking, sb, currentVNetworking); - currentVTracking = extractBoolean(keyRTracking, sb, currentVTracking); - currentVSessionTracking = extractBoolean(keyRSessionTracking, sb, currentVSessionTracking); - currentVCrashReporting = extractBoolean(keyRCrashReporting, sb, currentVCrashReporting); - currentVAutomaticSessionTracking = extractBoolean(keyRAutomaticSessionTracking, sb, currentVAutomaticSessionTracking); - currentVAutomaticViewTracking = extractBoolean(keyRAutomaticViewTracking, sb, currentVAutomaticViewTracking); - currentVAutomaticCrashReporting = extractBoolean(keyRAutomaticCrashReporting, sb, currentVAutomaticCrashReporting); - currentVViewTracking = extractBoolean(keyRViewTracking, sb, currentVViewTracking); - currentVCustomEventTracking = extractBoolean(keyRCustomEventTracking, sb, currentVCustomEventTracking); - currentVLocationTracking = extractBoolean(keyRLocationTracking, sb, currentVLocationTracking); - currentVContentZone = extractBoolean(keyREnterContentZone, sb, currentVContentZone); - currentVRefreshContentZone = extractBoolean(keyRRefreshContentZone, sb, currentVRefreshContentZone); - currentVBackoffMechanism = extractBoolean(keyRBackoffMechanism, sb, currentVBackoffMechanism); - currentVLoggingEnabled = extractBoolean(keyRLogging, sb, currentVLoggingEnabled); - currentVRequiresConsent = extractBoolean(keyRConsentRequired, sb, currentVRequiresConsent); - - currentVServerConfigUpdateInterval = extractInt(keyRServerConfigUpdateInterval, sb, currentVServerConfigUpdateInterval, 1); - currentVRequestQueueMaxSize = extractInt(keyRReqQueueSize, sb, currentVRequestQueueMaxSize, 1); - currentVEventQueueSizeThreshold = extractInt(keyREventQueueSize, sb, currentVEventQueueSizeThreshold, 1); - currentVSessionUpdateInterval = extractInt(keyRSessionUpdateInterval, sb, currentVSessionUpdateInterval, 1); - currentVMaxKeyLength = extractInt(keyRLimitKeyLength, sb, currentVMaxKeyLength, 1); - currentVMaxValueSize = extractInt(keyRLimitValueSize, sb, currentVMaxValueSize, 1); - currentVMaxSegmentationValues = extractInt(keyRLimitSegValues, sb, currentVMaxSegmentationValues, 1); - currentVMaxBreadcrumbCount = extractInt(keyRLimitBreadcrumb, sb, currentVMaxBreadcrumbCount, 1); - currentVMaxStackTraceLinesPerThread = extractInt(keyRLimitTraceLine, sb, currentVMaxStackTraceLinesPerThread, 1); - currentVMaxStackTraceLineLength = extractInt(keyRLimitTraceLength, sb, currentVMaxStackTraceLineLength, 1); - currentVUserPropertyCacheLimit = extractInt(keyRUserPropertyCacheLimit, sb, currentVUserPropertyCacheLimit, 1); - currentVBOMAcceptedTimeoutSeconds = extractInt(keyRBOMAcceptedTimeout, sb, currentVBOMAcceptedTimeoutSeconds, 1); - currentVBOMRQPercentage = extractFraction(keyRBOMRQPercentage, sb, currentVBOMRQPercentage); - currentVBOMRequestAge = extractInt(keyRBOMRequestAge, sb, currentVBOMRequestAge, 1); - currentVBOMDuration = extractInt(keyRBOMDuration, sb, currentVBOMDuration, 1); - currentVDropAgeHours = extractInt(keyRDropOldRequestTime, sb, currentVDropAgeHours, 0); - currentVZoneTimerInterval = extractInt(keyRContentZoneInterval, sb, currentVZoneTimerInterval, MIN_CONTENT_ZONE_INTERVAL); + ConfigurationProvider developer = developerValues; + + currentVNetworking = extractBoolean(keyRNetworking, sb, currentVNetworking, developer.getNetworkingEnabled()); + currentVTracking = extractBoolean(keyRTracking, sb, currentVTracking, developer.getTrackingEnabled()); + currentVSessionTracking = extractBoolean(keyRSessionTracking, sb, currentVSessionTracking, developer.getSessionTrackingEnabled()); + currentVCrashReporting = extractBoolean(keyRCrashReporting, sb, currentVCrashReporting, developer.getCrashReportingEnabled()); + currentVAutomaticSessionTracking = extractBoolean(keyRAutomaticSessionTracking, sb, currentVAutomaticSessionTracking, developer.getAutomaticSessionTrackingEnabled()); + currentVAutomaticViewTracking = extractBoolean(keyRAutomaticViewTracking, sb, currentVAutomaticViewTracking, developer.getAutomaticViewTrackingEnabled()); + currentVAutomaticCrashReporting = extractBoolean(keyRAutomaticCrashReporting, sb, currentVAutomaticCrashReporting, developer.getAutomaticCrashReportingEnabled()); + currentVViewTracking = extractBoolean(keyRViewTracking, sb, currentVViewTracking, developer.getViewTrackingEnabled()); + currentVCustomEventTracking = extractBoolean(keyRCustomEventTracking, sb, currentVCustomEventTracking, developer.getCustomEventTrackingEnabled()); + currentVLocationTracking = extractBoolean(keyRLocationTracking, sb, currentVLocationTracking, developer.getLocationTrackingEnabled()); + currentVContentZone = extractBoolean(keyREnterContentZone, sb, currentVContentZone, developer.getContentZoneEnabled()); + currentVRefreshContentZone = extractBoolean(keyRRefreshContentZone, sb, currentVRefreshContentZone, developer.getRefreshContentZoneEnabled()); + currentVBackoffMechanism = extractBoolean(keyRBackoffMechanism, sb, currentVBackoffMechanism, developer.getBOMEnabled()); + currentVLoggingEnabled = extractBoolean(keyRLogging, sb, currentVLoggingEnabled, developer.getLoggingEnabled()); + //the settings can turn the consent requirement on, never off when the developer turned it on + currentVRequiresConsent = developer.getConsentRequired() || extractBoolean(keyRConsentRequired, sb, currentVRequiresConsent, false); + + currentVServerConfigUpdateInterval = extractInt(keyRServerConfigUpdateInterval, sb, currentVServerConfigUpdateInterval, developer.getServerConfigUpdateInterval()); + currentVRequestQueueMaxSize = extractInt(keyRReqQueueSize, sb, currentVRequestQueueMaxSize, developer.getRequestQueueMaxSize()); + currentVEventQueueSizeThreshold = extractInt(keyREventQueueSize, sb, currentVEventQueueSizeThreshold, developer.getEventQueueSizeThreshold()); + currentVSessionUpdateInterval = extractInt(keyRSessionUpdateInterval, sb, currentVSessionUpdateInterval, developer.getSessionUpdateInterval()); + currentVMaxKeyLength = extractInt(keyRLimitKeyLength, sb, currentVMaxKeyLength, developer.getMaxKeyLength()); + currentVMaxValueSize = extractInt(keyRLimitValueSize, sb, currentVMaxValueSize, developer.getMaxValueSize()); + currentVMaxSegmentationValues = extractInt(keyRLimitSegValues, sb, currentVMaxSegmentationValues, developer.getMaxSegmentationValues()); + currentVMaxBreadcrumbCount = extractInt(keyRLimitBreadcrumb, sb, currentVMaxBreadcrumbCount, developer.getMaxBreadcrumbCount()); + currentVMaxStackTraceLinesPerThread = extractInt(keyRLimitTraceLine, sb, currentVMaxStackTraceLinesPerThread, developer.getMaxStackTraceLinesPerThread()); + currentVMaxStackTraceLineLength = extractInt(keyRLimitTraceLength, sb, currentVMaxStackTraceLineLength, developer.getMaxStackTraceLineLength()); + currentVUserPropertyCacheLimit = extractInt(keyRUserPropertyCacheLimit, sb, currentVUserPropertyCacheLimit, developer.getUserPropertyCacheLimit()); + currentVBOMAcceptedTimeoutSeconds = extractInt(keyRBOMAcceptedTimeout, sb, currentVBOMAcceptedTimeoutSeconds, developer.getBOMAcceptedTimeoutSeconds()); + currentVBOMRQPercentage = extractFraction(keyRBOMRQPercentage, sb, currentVBOMRQPercentage, developer.getBOMRQPercentage()); + currentVBOMRequestAge = extractInt(keyRBOMRequestAge, sb, currentVBOMRequestAge, developer.getBOMRequestAge()); + currentVBOMDuration = extractInt(keyRBOMDuration, sb, currentVBOMDuration, developer.getBOMDuration()); + currentVDropAgeHours = extractInt(keyRDropOldRequestTime, sb, currentVDropAgeHours, developer.getRequestDropAgeHours()); + currentVZoneTimerInterval = extractInt(keyRContentZoneInterval, sb, currentVZoneTimerInterval, developer.getContentZoneTimerInterval()); currentVRequestQueueMaxSizeFromBehaviorSettings = latestRetrievedConfiguration.has(keyRReqQueueSize); updateListingFilters(); @@ -604,82 +563,70 @@ synchronized boolean updateConfigVariables(@Nonnull InternalConfig config, @Null } /** - * Reads a switch from the merged settings. + * Resolves a switch from the stored settings. Validation keeps only usable values, so a stored + * value is used as it is. * * @param key the settings key * @param changedValues where a change is described * @param currentValue the value in effect - * @return the stored value, or the value in effect when none is stored + * @param fallbackValue the developer's value, or the SDK default when the developer sets none + * @return the stored value, or the fallback value when none is stored */ - private boolean extractBoolean(@Nonnull String key, @Nonnull StringBuilder changedValues, boolean currentValue) { + private boolean extractBoolean(@Nonnull String key, @Nonnull StringBuilder changedValues, boolean currentValue, boolean fallbackValue) { Object value = latestRetrievedConfiguration.opt(key); - if (!(value instanceof Boolean)) { - return currentValue; - } - - boolean extracted = (Boolean) value; - if (extracted != currentValue) { - changedValues.append(key).append(":[").append(extracted).append("], "); - } - return extracted; + return noteChange(key, changedValues, currentValue, value instanceof Boolean ? (Boolean) value : fallbackValue); } /** - * Reads an integer from the merged settings. + * Resolves an integer from the stored settings. Validation keeps only usable values, so a + * stored value is used as it is. * * @param key the settings key * @param changedValues where a change is described * @param currentValue the value in effect - * @param minimum the smallest valid value - * @return the stored value, or the value in effect when none, or none valid, is stored + * @param fallbackValue the developer's value, or the SDK default when the developer sets none + * @return the stored value, or the fallback value when none is stored */ - private int extractInt(@Nonnull String key, @Nonnull StringBuilder changedValues, int currentValue, int minimum) { + private int extractInt(@Nonnull String key, @Nonnull StringBuilder changedValues, int currentValue, int fallbackValue) { Object value = latestRetrievedConfiguration.opt(key); - if (!(value instanceof Integer)) { - return currentValue; - } - - int extracted = (Integer) value; - if (extracted < minimum) { - L.w("[ModuleConfiguration] updateConfigVariables, value for '" + key + "' is not valid, value: [" + extracted + "]"); - return currentValue; - } - - if (extracted != currentValue) { - changedValues.append(key).append(":[").append(extracted).append("], "); - } - return extracted; + return noteChange(key, changedValues, currentValue, value instanceof Integer ? (Integer) value : fallbackValue); } /** - * Reads a fraction strictly between 0 and 1 from the merged settings. + * Resolves a fraction from the stored settings. Validation keeps only usable values, so a + * stored value is used as it is. * * @param key the settings key * @param changedValues where a change is described * @param currentValue the value in effect - * @return the stored value, or the value in effect when none, or none valid, is stored + * @param fallbackValue the SDK default + * @return the stored value, or the fallback value when none is stored */ - private double extractFraction(@Nonnull String key, @Nonnull StringBuilder changedValues, double currentValue) { + private double extractFraction(@Nonnull String key, @Nonnull StringBuilder changedValues, double currentValue, double fallbackValue) { Object value = latestRetrievedConfiguration.opt(key); - if (!(value instanceof Number)) { - return currentValue; - } - - double extracted = ((Number) value).doubleValue(); - if (!isFraction(value)) { - L.w("[ModuleConfiguration] updateConfigVariables, value for '" + key + "' is not valid, value: [" + extracted + "]"); - return currentValue; - } + return noteChange(key, changedValues, currentValue, value instanceof Number ? ((Number) value).doubleValue() : fallbackValue); + } - if (Double.compare(extracted, currentValue) != 0) { - changedValues.append(key).append(":[").append(extracted).append("], "); + /** + * Describes a resolved value that differs from the value in effect. + * + * @param key the settings key + * @param changedValues where a change is described + * @param currentValue the value in effect + * @param resolvedValue the value just resolved + * @param the type of the value + * @return the resolved value + */ + private static T noteChange(@Nonnull String key, @Nonnull StringBuilder changedValues, @Nonnull T currentValue, @Nonnull T resolvedValue) { + if (!resolvedValue.equals(currentValue)) { + changedValues.append(key).append(":[").append(resolvedValue).append("], "); } - return extracted; + return resolvedValue; } /** - * Rebuilds the listing filters and the journey trigger sets from the merged settings and - * publishes each as a new immutable snapshot. A filter the merged settings do not hold is an + * Rebuilds the listing filters and the journey trigger sets from the stored settings and + * publishes each as a new immutable snapshot. A filter the stored settings do not hold is an * empty blacklist, which allows everything. */ private void updateListingFilters() { @@ -699,7 +646,7 @@ private void updateListingFilters() { } /** - * Builds a names filter from the merged settings, the blacklist winning when both are stored. + * Builds a names filter from the stored settings, the blacklist winning when both are stored. * * @param blacklistKey the settings key of the blacklist * @param whitelistKey the settings key of the whitelist @@ -721,7 +668,7 @@ private FilterList> namesFilterOf(@Nonnull String blacklistKey, @Non } /** - * Builds a names per event filter from the merged settings, the blacklist winning when both are stored. + * Builds a names per event filter from the stored settings, the blacklist winning when both are stored. * * @param blacklistKey the settings key of the blacklist * @param whitelistKey the settings key of the whitelist @@ -936,8 +883,8 @@ void fetchConfigFromServer(@Nonnull InternalConfig config) { } /** - * Handles the response of a settings request: strips the connection test flag, merges, stores - * and applies the settings, and tells the SDK when a resolved value changed. + * Handles the response of a settings request: strips the connection test flag, stores and + * applies the settings, and tells the SDK when a resolved value changed. * * @param config configuration of the running SDK * @param response the response, {@code null} when the request failed 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 e45c182e9..1be3a0eba 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 @@ -25,6 +25,11 @@ public class ModuleEvents extends ModuleBase { * response, which decides whether the content zone is refreshed. */ final Set journeyTriggerRequestIds = ConcurrentHashMap.newKeySet(); + /** + * The thread ending the timed events and the view of the previous device ID during a device ID + * change without merge; its sends wait, so those events go out in one request under that ID. + */ + private volatile Thread threadEndingEventsOfThePreviousDeviceId = null; @Override public void init(InternalConfig config) { @@ -104,20 +109,26 @@ public void deviceIdChanged(String oldDeviceId, boolean withMerge) { super.deviceIdChanged(oldDeviceId, withMerge); L.d("[ModuleEvents] deviceIdChanged: oldDeviceId = " + oldDeviceId + ", withMerge = " + withMerge); if (!withMerge) { - for (Map.Entry timedEventEntry : timedEvents.entrySet()) { - L.d("[ModuleEvents] deviceIdChanged, Ending timed event: [" + timedEventEntry.getKey() + "]"); - endEventInternal(timedEventEntry.getKey(), timedEventEntry.getValue().segmentation, timedEventEntry.getValue().count, timedEventEntry.getValue().sum); - } + //the new device ID is already set, so the events ended here are sent below under the old one, never on their own + threadEndingEventsOfThePreviousDeviceId = Thread.currentThread(); + try { + for (Map.Entry timedEventEntry : timedEvents.entrySet()) { + L.d("[ModuleEvents] deviceIdChanged, Ending timed event: [" + timedEventEntry.getKey() + "]"); + endEventInternal(timedEventEntry.getKey(), timedEventEntry.getValue().segmentation, timedEventEntry.getValue().count, timedEventEntry.getValue().sum); + } - // this part is to end and record the current view if exists - Session session = Countly.session(); - if (session != null && session.isActive()) { - View currentView = ((SessionImpl) session).currentView; - if (currentView != null) { - currentView.stop(true); - } else { - Storage.pushAsync(internalConfig, (SessionImpl) Countly.session()); + // this part is to end and record the current view if exists + Session session = Countly.session(); + if (session != null && session.isActive()) { + View currentView = ((SessionImpl) session).currentView; + if (currentView != null) { + currentView.stop(true); + } else { + Storage.pushAsync(internalConfig, (SessionImpl) Countly.session()); + } } + } finally { + threadEndingEventsOfThePreviousDeviceId = null; } addEventsToRequestQ(oldDeviceId); @@ -322,6 +333,10 @@ void checkEventQueueToSend(boolean forceSend) { * @param journeyTrigger whether the queue holds a journey trigger */ private void checkEventQueueToSend(boolean forceSend, boolean journeyTrigger) { + if (Thread.currentThread() == threadEndingEventsOfThePreviousDeviceId) { + L.d("[ModuleEvents] checkEventQueueToSend, the events of the previous device ID are being ended, they are sent together under that ID"); + return; + } L.d("[ModuleEvents] queue size:[" + eventQueue.eqSize() + "] || forceSend: " + forceSend); if (forceSend || eventQueue.eqSize() >= internalConfig.getConfigurationProvider().getEventQueueSizeThreshold()) { addEventsToRequestQ(null, journeyTrigger); diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationContentTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationContentTests.java index 1e1617b21..ac16e05d4 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationContentTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationContentTests.java @@ -201,11 +201,7 @@ public void contentZoneInterval_isReadWhenTheZoneIsEntered_andAChangeRestartsThe */ @Test public void journeyTriggers_sendTheWholeEventQueueRightAway_eachTriggerInItsOwnRequest() { - server.settings.respondWith(new ServerConfigBuilder() - .journeyTriggerEvents(names("purchase", "[CLY]_star_rating")) - .journeyTriggerViews(names("checkout")) - .eventQueueSize(100) - .networking(false)); + server.settings.respondWith(journeyTriggerSettings()); init(contentConfig().enableFeatures(Config.Feature.Views)); Countly.instance().events().recordEvent("regular"); @@ -245,9 +241,9 @@ public void journeyTriggers_sendTheWholeEventQueueRightAway_eachTriggerInItsOwnR Assert.assertEquals(storedRequestIds(), SDKCore.instance.module(ModuleEvents.class).journeyTriggerRequestIds); Countly.instance().views().startView("profile"); - push(new ServerConfigBuilder().eventFilterList(names("purchase"), false)); + push(journeyTriggerSettings().eventFilterList(names("purchase"), false)); Countly.instance().events().recordEvent("purchase"); - push(new ServerConfigBuilder().viewTracking(false)); + push(journeyTriggerSettings().viewTracking(false)); Assert.assertNull(Countly.instance().views().startView("checkout")); Assert.assertEquals(4, TestUtils.getCurrentRQ().length); TestUtils.validateEQSize(1); @@ -342,13 +338,17 @@ public void journeyTriggerView_refreshesLikeAnEvent_neverWhileViewTrackingIsOff_ assertZoneIsInactive(0); Assert.assertEquals("[\"[CLY]_view\",\"purchase\"]", eventKeys(queueServer.eventRequests().get(0)).toString()); - push(new ServerConfigBuilder().refreshContentZone(true).viewTracking(false)); + push(new ServerConfigBuilder().refreshContentZone(true).viewTracking(false) + .journeyTriggerViews(names("checkout")) + .journeyTriggerEvents(names("purchase"))); Assert.assertNull(Countly.instance().views().startView("checkout")); TestUtils.validateEQSize(0); Assert.assertEquals(0, queuedRequestCount()); assertZoneIsInactive(0); - push(new ServerConfigBuilder().viewTracking(true)); + push(new ServerConfigBuilder().viewTracking(true) + .journeyTriggerViews(names("checkout")) + .journeyTriggerEvents(names("purchase"))); Countly.instance().views().startView("checkout"); awaitZoneFetch(1); List> eventRequests = queueServer.eventRequests(); @@ -362,6 +362,21 @@ public void journeyTriggerView_refreshesLikeAnEvent_neverWhileViewTrackingIsOff_ // endregion // region helpers + /** + * The settings of the journey trigger scenario: two trigger events, one trigger view, a queue + * that waits for 100 events and networking off, so every request stays on disk. Each response + * replaces the stored settings, so every response of the scenario starts from these. + * + * @return a builder holding those settings + */ + private static ServerConfigBuilder journeyTriggerSettings() { + return new ServerConfigBuilder() + .journeyTriggerEvents(names("purchase", "[CLY]_star_rating")) + .journeyTriggerViews(names("checkout")) + .eventQueueSize(100) + .networking(false); + } + /** * Stands in for the settings and the content endpoints of the server. Settings requests go to a * {@link ModuleConfigurationTests.ServerConfigResponder}; content fetches are recorded and answered diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationFiltersTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationFiltersTests.java index cfdee0be0..fe54f6979 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationFiltersTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationFiltersTests.java @@ -166,7 +166,9 @@ public void segmentationFilters_generalThenPerEvent_filterACopyOfTheCallersMap_a perEventWhitelist.put("event1", names("key_a")); push(new ServerConfigBuilder() .segmentationFilterList(names("key_a", "key_b", "blocked_for_event1"), true) - .eventSegmentationFilterMap(perEventWhitelist, true)); + .eventSegmentationFilterMap(perEventWhitelist, true) + .eventQueueSize(1) + .networking(false)); Countly.instance().events().recordEvent("event1", callers); TestUtils.letTheClockCatchUp(); Countly.instance().events().recordEvent("event2", callers); diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationLimitsTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationLimitsTests.java index 55ec397db..e718ee499 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationLimitsTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationLimitsTests.java @@ -315,7 +315,7 @@ public void crashes_stackTraceLinesSegmentsAndBreadcrumbsAreCut_includingWhatACr *

* Verifies that an event recorded before any limit is sent whole, that after a response brings * key and value limits the same call is cut by them, that a later response lowering the key - * length and adding an entry limit is merged over the value limit it leaves out, that one raising + * length and adding an entry limit lifts the value limit it leaves out, that one raising * every limit sends the event whole again from the caller's untouched map, and that breadcrumbs * and stack trace lines follow the limits in effect when each breadcrumb is added and when the * crash is recorded. @@ -330,7 +330,7 @@ public void aResponseChangingTheLimits_appliesFromTheNextCall() { push(new ServerConfigBuilder().keyLengthLimit(8).valueSizeLimit(4)); Countly.instance().events().recordEvent("event_key", segmentation); push(new ServerConfigBuilder().keyLengthLimit(3).segmentationValuesLimit(1)); - Assert.assertEquals(4, provider().getMaxValueSize()); + Assert.assertEquals(ModuleConfiguration.NO_LIMIT, provider().getMaxValueSize()); Countly.instance().events().recordEvent("event_key", segmentation); push(new ServerConfigBuilder().keyLengthLimit(64).valueSizeLimit(64).segmentationValuesLimit(64)); Countly.instance().events().recordEvent("event_key", segmentation); @@ -344,7 +344,7 @@ public void aResponseChangingTheLimits_appliesFromTheNextCall() { push(new ServerConfigBuilder().valueSizeLimit(4)); Countly.instance().crashes().addCrashBreadcrumb("alpha_breadcrumb"); - push(new ServerConfigBuilder().breadcrumbLimit(1).traceLengthLimit(12)); + push(new ServerConfigBuilder().valueSizeLimit(4).breadcrumbLimit(1).traceLengthLimit(12)); Countly.instance().crashes().addCrashBreadcrumb("omega_breadcrumb"); Exception exception = new Exception("recorded after the change"); Countly.instance().crashes().recordHandledException(exception); diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationNetworkTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationNetworkTests.java index 8c85c1eb2..ec8184636 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationNetworkTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationNetworkTests.java @@ -240,7 +240,7 @@ public void backoff_offByTheDeveloper_staysOffWithASilentServer_andTheServerTurn Assert.assertTrue("the queue never drained, logs: " + logs, waitFor(() -> queueServer.received.size() == 2 && queuedRequestCount() == 0)); Assert.assertEquals(0, logsContaining(BACKOFF_STARTED)); - push(new ServerConfigBuilder().backoffMechanism(true).backoffDuration(2)); + push(new ServerConfigBuilder().backoffMechanism(true).backoffAcceptedTimeout(1).backoffDuration(2)); Assert.assertTrue(provider().getBOMEnabled()); Assert.assertEquals(2, provider().getBOMDuration()); @@ -393,7 +393,7 @@ public void requestDropAge_settlesTheJourneyTriggerOfADroppedRequest_andALaterTr Assert.assertEquals(1, pending.size()); ageStoredRequests(2 * HOUR_MS); - push(new ServerConfigBuilder().networking(true)); + push(new ServerConfigBuilder().networking(true).journeyTriggerEvents(names("purchase")).dropOldRequestTime(1)); Assert.assertTrue("the aged request was never dropped, logs: " + logs, waitNudging(() -> queuedRequestCount() == 0)); Assert.assertEquals(1, logsContaining(DROPPED_UNSENT)); Assert.assertEquals(1, logsContaining("carrying a journey trigger failed with code [" + Transport.NO_RESPONSE_CODE + "], the content zone is not refreshed")); diff --git a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationTests.java index 0cfa1c4e3..de2182bb7 100644 --- a/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationTests.java +++ b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationTests.java @@ -33,7 +33,7 @@ /** * SDK behavior settings, the server side configuration served by {@code /o/sdk?method=sc}: how - * {@link ModuleConfiguration} validates, merges, stores and resolves them, and how the SDK applies + * {@link ModuleConfiguration} validates, stores and resolves them, and how the SDK applies * the values it owns at init and when a response changes them. *

* The settings request goes through a {@link ServerConfigResponder} injected on the @@ -138,11 +138,11 @@ public void removeUnsupportedKeys_keepsEveryValidValueAndDropsTheRest() throws I * differs from the default, then every key at an invalid value. *

* Verifies that each value reaches its provider getter and the store, that the request queue size - * is reported as coming from the settings, and that a response made only of invalid values changes - * nothing, neither the resolved values nor the stored settings. + * is reported as coming from the settings, and that a response made only of invalid values stores + * no setting and brings every one back to the developer value, as one more change. */ @Test - public void allSettings_resolveFromAResponse_andInvalidValuesNeverOverrideThem() throws IllegalAccessException { + public void allSettings_resolveFromAResponse_andInvalidValuesFallBackToTheDeveloperValues() throws IllegalAccessException { AtomicInteger changes = installCountingModule().changes; ServerConfigBuilder allKeys = new ServerConfigBuilder().allKeysAtNonDefaultValues(); server.respondWith(allKeys); @@ -160,9 +160,20 @@ public void allSettings_resolveFromAResponse_andInvalidValuesNeverOverrideThem() } push(allInvalid); - allKeys.validateAgainst(provider()); - Assert.assertEquals(1, changes.get()); - Assert.assertTrue(storedSettings.similar(storedConfig().getJSONObject("c"))); + ConfigurationProvider developer = ModuleConfiguration.developerDefaults(new InternalConfig(TestUtils.getConfigSdkBehaviorSettings())); + Set nameFilterKeys = names("eb", "ew", "upb", "upw", "sb", "sw", "esb", "esw"); + for (String key : allKeys.config.keySet()) { + if (!nameFilterKeys.contains(key)) { + Assert.assertEquals("value of '" + key + "'", ServerConfigBuilder.valueOf(developer, key), ServerConfigBuilder.valueOf(provider(), key)); + } + } + assertFilter(provider().getEventFilterList(), false, names()); + assertFilter(provider().getUserPropertyFilterList(), false, names()); + assertFilter(provider().getSegmentationFilterList(), false, names()); + Assert.assertTrue(provider().getEventSegmentationFilterList().getFilterList().isEmpty()); + Assert.assertFalse(provider().isRequestQueueMaxSizeFromBehaviorSettings()); + Assert.assertEquals(2, changes.get()); + Assert.assertEquals(0, storedConfig().getJSONObject("c").length()); } /** @@ -222,11 +233,12 @@ public void envelope_requiresVersionTimestampAndSettingsObject_andOnlyThoseAreSt * stored, then the stored settings, then each server response. *

* Verifies each layer overriding the one below it, that the provided settings are stored and - * ignored once something is stored, that a response merges into the stored settings instead of - * replacing them, and that the merge survives {@code stop()} and a new init. + * ignored once something is stored, that a response replaces the stored settings, so a setting it + * leaves out goes back to the developer value, and that the replacement survives {@code stop()} + * and a new init. */ @Test - public void precedence_developerThenProvidedThenStoredThenServer_andTheMergeSurvivesARestart() { + public void precedence_developerThenProvidedThenStoredThenServer_andAResponseReplacesTheStoredSettings() { server.pending(); init(developerConfig()); @@ -252,19 +264,20 @@ public void precedence_developerThenProvidedThenStoredThenServer_andTheMergeSurv push(new ServerConfigBuilder().eventQueueSize(33).breadcrumbLimit(5).logging(false)); Assert.assertEquals(33, provider().getEventQueueSizeThreshold()); - Assert.assertEquals(22, provider().getRequestQueueMaxSize()); + Assert.assertEquals(50, provider().getRequestQueueMaxSize()); + Assert.assertFalse(provider().isRequestQueueMaxSizeFromBehaviorSettings()); Assert.assertEquals(5, provider().getMaxBreadcrumbCount()); Assert.assertEquals(30, provider().getSessionUpdateInterval()); Assert.assertFalse(provider().getLoggingEnabled()); JSONObject storedSettings = storedConfig().getJSONObject("c"); - Assert.assertEquals(names("eqs", "rqs", "lbc", "log"), storedSettings.keySet()); + Assert.assertEquals(names("eqs", "lbc", "log"), storedSettings.keySet()); Countly.instance().stop(); server.failing(); init(developerConfig().setSdkBehaviorSettings(new ServerConfigBuilder().eventQueueSize(99).sessionTracking(false).build())); Assert.assertEquals(33, provider().getEventQueueSizeThreshold()); - Assert.assertEquals(22, provider().getRequestQueueMaxSize()); + Assert.assertEquals(50, provider().getRequestQueueMaxSize()); Assert.assertEquals(5, provider().getMaxBreadcrumbCount()); Assert.assertFalse(provider().getLoggingEnabled()); Assert.assertTrue(provider().getSessionTrackingEnabled()); @@ -504,11 +517,11 @@ public void customEventTracking_off_dropsCustomEventsOnly_whileTrackingGatesEver * Listing filters and journey trigger sets are published as immutable snapshots. *

* Verifies every filter family and both journey sets as parsed, that a reader's snapshot never - * changes under it, that a response with any whitelist drops every stored blacklist and the - * reverse, that a response with neither keeps the stored filters, and that they survive a restart. + * changes under it, that each response replaces every filter and journey set, so one it leaves out + * is turned off, and that the filters of the last response survive a restart. */ @Test - public void listingFilters_arePublishedAsSnapshots_andOneKindReplacesTheStoredOtherKind() { + public void listingFilters_arePublishedAsSnapshots_andEachResponseReplacesThem() { Map> perEvent = new LinkedHashMap<>(); perEvent.put("purchase", names("card")); server.respondWith(new ServerConfigBuilder() @@ -543,27 +556,148 @@ public void listingFilters_arePublishedAsSnapshots_andOneKindReplacesTheStoredOt assertFilter(events, false, names("blocked_a", "blocked_b")); assertFilter(provider().getEventFilterList(), true, names("allowed_event")); assertFilter(provider().getSegmentationFilterList(), false, names()); - assertFilter(provider().getUserPropertyFilterList(), true, names("plan")); + assertFilter(provider().getUserPropertyFilterList(), false, names()); Assert.assertTrue(provider().getEventSegmentationFilterList().getFilterList().isEmpty()); - Assert.assertEquals(names("checkout"), provider().getJourneyTriggerEvents()); - Assert.assertEquals(names("ew", "upw", "jte", "jtv"), storedConfig().getJSONObject("c").keySet()); + Assert.assertTrue(provider().getJourneyTriggerEvents().isEmpty()); + Assert.assertTrue(provider().getJourneyTriggerViews().isEmpty()); + Assert.assertEquals(names("ew"), storedConfig().getJSONObject("c").keySet()); push(new ServerConfigBuilder().tracking(false)); - assertFilter(provider().getEventFilterList(), true, names("allowed_event")); - - push(new ServerConfigBuilder().segmentationFilterList(names("secret"), false)); assertFilter(provider().getEventFilterList(), false, names()); - assertFilter(provider().getUserPropertyFilterList(), false, names()); + + push(new ServerConfigBuilder().segmentationFilterList(names("secret"), false).journeyTriggerEvents(names("checkout"))); assertFilter(provider().getSegmentationFilterList(), false, names("secret")); - Assert.assertEquals(names("jte", "jtv", "tracking", "sb"), storedConfig().getJSONObject("c").keySet()); + Assert.assertEquals(names("checkout"), provider().getJourneyTriggerEvents()); + Assert.assertTrue(provider().getTrackingEnabled()); + Assert.assertEquals(names("sb", "jte"), storedConfig().getJSONObject("c").keySet()); Countly.instance().stop(); server.pending(); init(TestUtils.getConfigSdkBehaviorSettings()); assertFilter(provider().getSegmentationFilterList(), false, names("secret")); Assert.assertEquals(names("checkout"), provider().getJourneyTriggerEvents()); - Assert.assertEquals(names("paywall"), provider().getJourneyTriggerViews()); + Assert.assertTrue(provider().getJourneyTriggerViews().isEmpty()); + Assert.assertTrue(provider().getTrackingEnabled()); + } + + /** + * A setting the server stops sending goes back to the developer value, or to the SDK default when + * the developer sets none, instead of keeping the last server value. + *

+ * Verifies with a developer configuration that sets the event and request queue sizes and the + * breadcrumb limit that a response leaving out tracking, the event queue size, the key length + * limit and an event blacklist brings back the default tracking, the developer's queue size, no + * key length limit and no event filter, that only what that response carries is stored, and that + * a response with no settings, which the server sends once every setting is removed, brings back + * every developer value and stores no setting. + */ + @Test + public void removedSettings_goBackToTheDeveloperValues_andAnEmptyResponseBringsBackAll() { + server.respondWith(new ServerConfigBuilder().tracking(false).eventQueueSize(3).keyLengthLimit(20).breadcrumbLimit(5) + .requestQueueSize(22).eventFilterList(names("blocked_event"), false)); + init(developerConfig()); Assert.assertFalse(provider().getTrackingEnabled()); + Assert.assertEquals(3, provider().getEventQueueSizeThreshold()); + Assert.assertEquals(20, provider().getMaxKeyLength()); + assertFilter(provider().getEventFilterList(), false, names("blocked_event")); + + push(new ServerConfigBuilder().networking(true).requestQueueSize(22).breadcrumbLimit(5)); + Assert.assertTrue(provider().getTrackingEnabled()); + Assert.assertEquals(7, provider().getEventQueueSizeThreshold()); + Assert.assertEquals(ModuleConfiguration.NO_LIMIT, provider().getMaxKeyLength()); + assertFilter(provider().getEventFilterList(), false, names()); + Assert.assertEquals(22, provider().getRequestQueueMaxSize()); + Assert.assertEquals(5, provider().getMaxBreadcrumbCount()); + Assert.assertEquals(names("networking", "rqs", "lbc"), storedConfig().getJSONObject("c").keySet()); + + push("{'v':1,'t':3,'c':{}}"); + Assert.assertEquals(50, provider().getRequestQueueMaxSize()); + Assert.assertFalse(provider().isRequestQueueMaxSizeFromBehaviorSettings()); + Assert.assertEquals(40, provider().getMaxBreadcrumbCount()); + Assert.assertTrue(provider().getLoggingEnabled()); + Assert.assertEquals(0, storedConfig().getJSONObject("c").length()); + } + + /** + * Settings provided at init stand in for the server's until its first response, which replaces + * them. + *

+ * Verifies that the provided settings apply at init, and that the first response, carrying only + * the request queue size, brings tracking and the event queue size back to the developer values + * and is all that is stored. + */ + @Test + public void providedSettings_applyUntilTheFirstResponse() { + server.pending(); + init(developerConfig().setSdkBehaviorSettings(new ServerConfigBuilder().tracking(false).eventQueueSize(11).build())); + Assert.assertFalse(provider().getTrackingEnabled()); + Assert.assertEquals(11, provider().getEventQueueSizeThreshold()); + + push(new ServerConfigBuilder().requestQueueSize(500)); + Assert.assertTrue(provider().getTrackingEnabled()); + Assert.assertEquals(7, provider().getEventQueueSizeThreshold()); + Assert.assertEquals(500, provider().getRequestQueueMaxSize()); + Assert.assertEquals(names("rqs"), storedConfig().getJSONObject("c").keySet()); + } + + /** + * The settings can add a consent requirement but never lift the one the developer set. + *

+ * Verifies with {@link Config#setRequiresConsent(boolean)} on that a provided, a live and a stored + * {@code cr} false all leave consent required, so the feature modules wait for consent, and that + * without the developer's requirement a {@code cr} true is lifted again by a response without it. + */ + @Test + public void consentRequirement_aFalseFromTheSettingsNeverLiftsTheDevelopersRequirement() { + server.respondWith(new ServerConfigBuilder().consentRequired(false)); + init(TestUtils.getConfigSdkBehaviorSettings().setRequiresConsent(true).enableFeatures(Config.Feature.Events) + .setSdkBehaviorSettings(new ServerConfigBuilder().consentRequired(false).build())); + + Assert.assertTrue(provider().getConsentRequired()); + Assert.assertTrue(SDKCore.instance.config.requiresConsent()); + Assert.assertFalse(Countly.isTracking(Config.Feature.Events)); + Assert.assertFalse(storedConfig().getJSONObject("c").getBoolean("cr")); + Countly.instance().stop(); + + server.pending(); + init(TestUtils.getConfigSdkBehaviorSettings().setRequiresConsent(true).enableFeatures(Config.Feature.Events)); + Assert.assertTrue(provider().getConsentRequired()); + Assert.assertTrue(SDKCore.instance.config.requiresConsent()); + Assert.assertFalse(Countly.isTracking(Config.Feature.Events)); + Countly.instance().halt(); + + server = new ServerConfigResponder().respondWith(new ServerConfigBuilder().consentRequired(true)); + init(TestUtils.getConfigSdkBehaviorSettings().enableFeatures(Config.Feature.Events)); + Assert.assertTrue(provider().getConsentRequired()); + push(new ServerConfigBuilder().tracking(true)); + Assert.assertFalse(provider().getConsentRequired()); + } + + /** + * A journey trigger that a device ID change without merge ends is sent with the other events of + * the previous device ID, under that ID. + *

+ * Verifies with {@code checkout} as a journey trigger and networking off that ending a timed + * {@code checkout} on the change does not send the queue under the new ID: the queued event and + * the trigger are stored in one request that carries the previous device ID, and that request + * refreshes no content zone for the new device ID. + */ + @Test + public void journeyTrigger_endedByADeviceIdChange_isSentUnderThePreviousDeviceId() { + server.respondWith(new ServerConfigBuilder().journeyTriggerEvents(names("checkout")).eventQueueSize(100).networking(false)); + init(TestUtils.getConfigSdkBehaviorSettings().enableFeatures(Config.Feature.Events)); + + Countly.instance().events().recordEvent("old_user_event"); + Countly.instance().events().startEvent("checkout"); + Countly.instance().deviceId().changeWithoutMerge("new_device_id"); + TestUtils.letTheClockCatchUp(); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + List events = TestUtils.readEventsFromRequest(0, TestUtils.DEVICE_ID); + Assert.assertEquals(2, events.size()); + Assert.assertEquals("old_user_event", events.get(0).key); + Assert.assertEquals("checkout", events.get(1).key); + Assert.assertTrue(SDKCore.instance.module(ModuleEvents.class).journeyTriggerRequestIds.isEmpty()); } /**