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 852c5214..5cf1f4c2 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/JsonFileStorage.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/JsonFileStorage.java index de2f26f9..51d17cc5 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/main/java/ly/count/sdk/java/internal/ModuleConfiguration.java b/sdk-java/src/main/java/ly/count/sdk/java/internal/ModuleConfiguration.java index e5e9f2f4..c3ea8814 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 e45c182e..1be3a0eb 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/JsonFileStorageTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/JsonFileStorageTests.java index bf047fa1..09fccd1f 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/ModuleConfigurationContentTests.java b/sdk-java/src/test/java/ly/count/sdk/java/internal/ModuleConfigurationContentTests.java index 1e1617b2..ac16e05d 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 cfdee0be..fe54f697 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 55ec397d..e718ee49 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 8c85c1eb..ec818463 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 0cfa1c4e..de2182bb 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()); } /** 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 da006423..619ae55e 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.