diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7342f45..b9fca17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
## XX.XX.XX
* Added support for SDK Behavior Settings (Server Config), enabled by default:
* The server can gate "tracking" and "networking", enforce consent ("cr", enable-only), and override request/event queue sizes, session update interval, logging, and the SDK limits (key/value/segmentation/breadcrumb/stack-trace).
+ * The server can also gate individual features: sessions ("st"), views ("vt"), custom events ("cet"), crash reporting ("crt"), location ("lt"), and content ("ecz" to enter the content zone automatically, "rcz" to allow refreshing it, "czi" to override the content zone poll interval).
+ * The server can also filter recorded data: event blacklist/whitelist ("eb"/"ew"), segmentation key filters ("sb"/"sw"), per-event segmentation filters ("esb"/"esw"), and custom user property filters ("upb"/"upw") with a cache limit ("upcl").
+ * Added journey trigger events ("jte"): recording a listed custom event refreshes the content zone once the event is delivered.
+ * Added drop-old-request time ("dort"): queued requests older than the configured number of hours are dropped before upload (0 disables this).
* Added configuration option "SetSDKBehaviorSettings" to seed behavior settings for first run / offline.
* Added configuration option "DisableSDKBehaviorSettingsUpdates" to stop only the network fetch (provided and stored settings still apply).
* Added support for Remote Config feature accesible through "Countly.Instance.RemoteConfig()" interface:
@@ -26,6 +30,7 @@
* This feature uses "Content" consent.
* Needs "Countly.UI.WebView2" for displaying content on WPF.
* Fixed a bug where the request queue was not processed when automatic user property saving ("autoSendUserDetails") was disabled, so queued requests (for example from a manual session update) were never uploaded and could cause the SDK to loop indefinitely.
+* Fixed a bug where a user-details change that serialized to nothing (empty user details) left the internal "changed" flag set forever, so the SDK kept re-processing user details on every session call and upload-completion checks could wait indefinitely.
## 25.4.3
* ! Minor breaking change ! User properties will now be automatically saved under the following conditions:
diff --git a/countlyCommon/TestingRelated/ModuleServerConfigTests.cs b/countlyCommon/TestingRelated/ModuleServerConfigTests.cs
index 2be0316..e852f09 100644
--- a/countlyCommon/TestingRelated/ModuleServerConfigTests.cs
+++ b/countlyCommon/TestingRelated/ModuleServerConfigTests.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using CountlySDK;
using CountlySDK.CountlyCommon;
+using CountlySDK.CountlyCommon.Entities;
using CountlySDK.Entities;
using CountlySDK.Helpers;
using Xunit;
@@ -205,6 +206,353 @@ public void Networking_Disabled_BlocksUpload_ButQueues()
server.Dispose();
}
+ [Fact]
+ /// Feature gates default to enabled (only 'ecz' defaults to disabled).
+ public void FeatureGates_Defaults()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ ModuleServerConfig sc = Countly.Instance.moduleServerConfig;
+ Assert.True(sc.GetSessionTrackingEnabled());
+ Assert.True(sc.GetViewTrackingEnabled());
+ Assert.True(sc.GetCustomEventTrackingEnabled());
+ Assert.True(sc.GetCrashReportingEnabled());
+ Assert.True(sc.GetLocationTrackingEnabled());
+ Assert.True(sc.GetRefreshContentZoneEnabled());
+ Assert.False(sc.GetEnterContentZoneEnabled());
+ }
+
+ [Fact]
+ /// st=false blocks session begin/update/end requests.
+ public void SessionTracking_Disabled_BlocksSessionRequests()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"st\":false}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+ Countly.Instance.StoredRequests.Clear();
+
+ Countly.Instance.SessionBegin().Wait();
+ Countly.Instance.SessionUpdate(30).Wait();
+ Countly.Instance.SessionEnd().Wait();
+
+ Assert.Empty(Countly.Instance.StoredRequests);
+ }
+
+ [Fact]
+ /// cet=false blocks custom events but reserved events (views) still record.
+ public void CustomEventTracking_Disabled_BlocksCustomEvents_AllowsViews()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"cet\":false}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Countly.RecordEvent("custom_event", 1, null, null).Wait();
+ Assert.Empty(Countly.Instance.Events);
+
+ Countly.Instance.RecordView("home").Wait();
+ Assert.Single(Countly.Instance.Events);
+ Assert.Equal("[CLY]_view", Countly.Instance.Events[0].Key);
+ }
+
+ [Fact]
+ /// vt=false blocks view recording; custom events still record.
+ public void ViewTracking_Disabled_BlocksViews_AllowsCustomEvents()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"vt\":false}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ bool viewResult = Countly.Instance.RecordView("home").Result;
+ Assert.False(viewResult);
+ Assert.Empty(Countly.Instance.Events);
+
+ Countly.RecordEvent("custom_event", 1, null, null).Wait();
+ Assert.Single(Countly.Instance.Events);
+ Assert.Equal("custom_event", Countly.Instance.Events[0].Key);
+ }
+
+ [Fact]
+ /// crt=false blocks crash recording.
+ public void CrashReporting_Disabled_BlocksExceptions()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"crt\":false}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Countly.RecordException("some error").Wait();
+ Assert.Empty(Countly.Instance.Exceptions);
+ }
+
+ [Fact]
+ /// lt=false ignores location calls and blanks the session location.
+ public void LocationTracking_Disabled_IgnoresLocationCalls()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"lt\":false}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+ Countly.Instance.StoredRequests.Clear();
+
+ Countly.Instance.SetLocation("1.1,2.2", "1.1.1.1", "US", "Ankara").Wait();
+ Countly.Instance.DisableLocation().Wait();
+ Assert.Empty(Countly.Instance.StoredRequests);
+
+ // with location tracking off, begin_session must carry an empty location param
+ Countly.Instance.SessionBegin().Wait();
+ Assert.Single(Countly.Instance.StoredRequests);
+ StoredRequest sr = Countly.Instance.StoredRequests.Peek();
+ Assert.Contains("begin_session", sr.Request);
+ Assert.Contains("location=", sr.Request);
+ Assert.DoesNotContain("1.1%2C2.2", sr.Request);
+ }
+
+ [Fact]
+ /// Content keys apply: ecz/rcz to the provider, czi onto the configuration.
+ public void ContentKeys_Applied()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"ecz\":true,\"rcz\":false,\"czi\":20}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.True(Countly.Instance.moduleServerConfig.GetEnterContentZoneEnabled());
+ Assert.False(Countly.Instance.moduleServerConfig.GetRefreshContentZoneEnabled());
+ Assert.Equal(20, Countly.Instance.Configuration.ContentZoneTimerInterval);
+ }
+
+ [Fact]
+ /// Invalid feature-gate values are dropped: wrong types, and czi below 16.
+ public void Sanitize_DropsInvalidFeatureGateValues()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"st\":\"nope\",\"vt\":1,\"czi\":10}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.True(Countly.Instance.moduleServerConfig.GetSessionTrackingEnabled());
+ Assert.True(Countly.Instance.moduleServerConfig.GetViewTrackingEnabled());
+ Assert.Equal(30, Countly.Instance.Configuration.ContentZoneTimerInterval);
+ }
+
+ private static List SegKeys(CountlyEvent e)
+ {
+ List keys = new List();
+ foreach (SegmentationItem si in e.Segmentation.segmentation) { keys.Add(si.Key); }
+ return keys;
+ }
+
+ [Fact]
+ /// eb blocks the listed custom events, others still record.
+ public void EventFilter_Blacklist_BlocksListedEvents()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"eb\":[\"bad_event\"]}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Countly.RecordEvent("bad_event", 1, null, null).Wait();
+ Assert.Empty(Countly.Instance.Events);
+
+ Countly.RecordEvent("good_event", 1, null, null).Wait();
+ Assert.Single(Countly.Instance.Events);
+ Assert.Equal("good_event", Countly.Instance.Events[0].Key);
+ }
+
+ [Fact]
+ /// ew allows only the listed custom events; reserved events are unaffected.
+ public void EventFilter_Whitelist_AllowsOnlyListed_ReservedUnaffected()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"ew\":[\"good_event\"]}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Countly.RecordEvent("other_event", 1, null, null).Wait();
+ Assert.Empty(Countly.Instance.Events);
+
+ Countly.RecordEvent("good_event", 1, null, null).Wait();
+ Countly.Instance.RecordView("home").Wait();
+ Assert.Equal(2, Countly.Instance.Events.Count); // whitelisted custom event + reserved view event
+ }
+
+ [Fact]
+ /// sb strips the listed segmentation keys from custom events.
+ public void SegmentationBlacklist_StripsKeys()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"sb\":[\"secret\"]}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Segmentation seg = new Segmentation();
+ seg.Add("secret", "1");
+ seg.Add("keep", "2");
+ Countly.RecordEvent("e", 1, seg).Wait();
+
+ Assert.Single(Countly.Instance.Events);
+ List keys = SegKeys(Countly.Instance.Events[0]);
+ Assert.DoesNotContain("secret", keys);
+ Assert.Contains("keep", keys);
+ }
+
+ [Fact]
+ /// sw keeps only the listed segmentation keys on custom events.
+ public void SegmentationWhitelist_KeepsOnlyListed()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"sw\":[\"keep\"]}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Segmentation seg = new Segmentation();
+ seg.Add("keep", "1");
+ seg.Add("other", "2");
+ Countly.RecordEvent("e", 1, seg).Wait();
+
+ List keys = SegKeys(Countly.Instance.Events[0]);
+ Assert.Contains("keep", keys);
+ Assert.DoesNotContain("other", keys);
+ }
+
+ [Fact]
+ /// esb applies per event: only the named event loses the listed keys.
+ public void EventSegmentationFilter_AppliesPerEvent()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"esb\":{\"e1\":[\"a\"]}}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Segmentation seg1 = new Segmentation();
+ seg1.Add("a", "1");
+ seg1.Add("b", "2");
+ Countly.RecordEvent("e1", 1, seg1).Wait();
+
+ Segmentation seg2 = new Segmentation();
+ seg2.Add("a", "1");
+ Countly.RecordEvent("e2", 1, seg2).Wait();
+
+ List e1Keys = SegKeys(Countly.Instance.Events[0]);
+ Assert.DoesNotContain("a", e1Keys);
+ Assert.Contains("b", e1Keys);
+ Assert.Contains("a", SegKeys(Countly.Instance.Events[1])); // no rule for e2, all allowed
+ }
+
+ [Fact]
+ /// upb removes the listed keys from custom user properties.
+ public void UserPropertyFilter_Blacklist_RemovesFilteredKeys()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"upb\":[\"secret\"]}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ cc.DisableManualUserDetailsSave().DisableAutoSendUserDetails();
+ Countly.Instance.Init(cc).Wait();
+
+ Countly.UserDetails.Custom.Add("secret", "1");
+ Countly.UserDetails.Custom.Add("ok", "2");
+
+ Dictionary custom = Countly.UserDetails.Custom.ToDictionary();
+ Assert.False(custom.ContainsKey("secret"));
+ Assert.Equal("2", custom["ok"]);
+ }
+
+ [Fact]
+ /// upcl caps the custom user property count (oldest kept).
+ public void UserPropertyCacheLimit_TrimsOverflow()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"upcl\":2}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ cc.DisableManualUserDetailsSave().DisableAutoSendUserDetails();
+ Countly.Instance.Init(cc).Wait();
+
+ Countly.UserDetails.Custom.Add("k1", "1");
+ Countly.UserDetails.Custom.Add("k2", "2");
+ Countly.UserDetails.Custom.Add("k3", "3");
+
+ Dictionary custom = Countly.UserDetails.Custom.ToDictionary();
+ Assert.Equal(2, custom.Count);
+ Assert.True(custom.ContainsKey("k1"));
+ Assert.True(custom.ContainsKey("k2"));
+ Assert.False(custom.ContainsKey("k3"));
+ }
+
+ [Fact]
+ /// jte is parsed into the journey trigger set.
+ public void JourneyTriggerEvents_Parsed()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"jte\":[\"e1\"]}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.True(Countly.Instance.moduleServerConfig.IsJourneyTriggerEvent("e1"));
+ Assert.False(Countly.Instance.moduleServerConfig.IsJourneyTriggerEvent("e2"));
+ }
+
+ [Fact]
+ /// dort drops queued requests older than the limit on upload; fresh ones stay.
+ public void DropOldRequests_PrunedOnUpload()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"dort\":1}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+ Countly.Instance.StoredRequests.Clear();
+
+ long nowMs = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds;
+ long oldMs = nowMs - (2L * 60L * 60L * 1000L); // 2h old, limit is 1h
+ Countly.Instance.StoredRequests.Enqueue(new StoredRequest("/i?app_key=APP_KEY×tamp=" + oldMs + "&end_session=1"));
+ Countly.Instance.StoredRequests.Enqueue(new StoredRequest("/i?app_key=APP_KEY×tamp=" + nowMs + "&end_session=1"));
+
+ Countly.Instance.Upload().Wait();
+
+ Assert.Single(Countly.Instance.StoredRequests);
+ Assert.Contains("timestamp=" + nowMs, Countly.Instance.StoredRequests.Peek().Request);
+ }
+
+ [Fact]
+ /// An incoming blacklist evicts the stored whitelist of the same category (and applies).
+ public void FilterLists_IncomingBlacklistEvictsStoredWhitelist()
+ {
+ Storage.Instance.SaveToFile(
+ ModuleServerConfig.serverConfigFilename,
+ new ServerConfigEntity { Json = "{\"v\":1,\"t\":1,\"c\":{\"ew\":[\"a\"]}}" }).Wait();
+ MockHttpServer server = new MockHttpServer((body) =>
+ body.Contains("method=sc") ? "{\"v\":1,\"t\":2,\"c\":{\"eb\":[\"c\"]}}" : null);
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.serverUrl = server.Url;
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.True(Countly.Instance.moduleServerConfig.IsEventKeyAllowed("b")); // blacklist mode now
+ Assert.False(Countly.Instance.moduleServerConfig.IsEventKeyAllowed("c"));
+
+ ServerConfigEntity stored = Storage.Instance.LoadFromFile(ModuleServerConfig.serverConfigFilename).Result;
+ Assert.Contains("\"eb\"", stored.Json);
+ Assert.DoesNotContain("\"ew\"", stored.Json);
+ server.Dispose();
+ }
+
+ [Fact]
+ /// Invalid filter values are dropped: non-array lists, negative dort, non-positive upcl.
+ public void Sanitize_DropsInvalidFilterValues()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"eb\":\"notarray\",\"esb\":[\"notobject\"],\"dort\":-1,\"upcl\":0}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.True(Countly.Instance.moduleServerConfig.IsEventKeyAllowed("anything"));
+ Assert.Equal(0, Countly.Instance.moduleServerConfig.GetDropOldRequestTimeHours());
+ Assert.Equal(100, Countly.Instance.moduleServerConfig.GetUserPropertyCacheLimit());
+ }
+
[Fact]
/// A device-ID change triggers an additional SBS fetch.
public void ChangeDeviceId_TriggersRefetch()
diff --git a/countlyCommon/TestingRelated/UserDetailsTests.cs b/countlyCommon/TestingRelated/UserDetailsTests.cs
index 27b9b12..3ef23cc 100644
--- a/countlyCommon/TestingRelated/UserDetailsTests.cs
+++ b/countlyCommon/TestingRelated/UserDetailsTests.cs
@@ -120,6 +120,27 @@ public void SetUserDetails_SessionTriggers()
server.Dispose();
}
+ [Fact]
+ ///
+ /// A user-details change notification with EMPTY details (nothing to send) must not leave
+ /// 'isChanged' stuck true - upload waiters (e.g. ValidateDataPointUpload) would spin on it forever.
+ ///
+ public void EmptyUserDetails_SessionFlow_DoesNotStayChanged()
+ {
+ var server = new MockHttpServer();
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.serverUrl = server.Url;
+ Countly.Instance.Init(cc).Wait();
+
+ // Arm the pending-change flag while the details themselves stay empty ("{}"),
+ // mirroring what the lazy first load of UserDetails does on a fresh run.
+ Countly.UserDetails._custom = new Dictionary();
+ Countly.Instance.SessionBegin().Wait();
+
+ Assert.False(Countly.UserDetails.isChanged);
+ server.Dispose();
+ }
+
[Fact]
///
/// It validates that user property changes are not triggered with session calls when disabled
diff --git a/countlyCommon/countlyCommon/CountlyBase.cs b/countlyCommon/countlyCommon/CountlyBase.cs
index 502d1d7..c2dd897 100644
--- a/countlyCommon/countlyCommon/CountlyBase.cs
+++ b/countlyCommon/countlyCommon/CountlyBase.cs
@@ -224,6 +224,11 @@ protected async Task UpdateSessionInternal(int? elapsedTime = null)
return;
}
+ if (moduleServerConfig != null && !moduleServerConfig.GetSessionTrackingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] UpdateSessionInternal, session tracking disabled by server config, ignoring");
+ return;
+ }
+
if (elapsedTime == null) {
//calculate elapsed time from the last time update was sent (includes manual calls)
elapsedTime = (int)DateTime.Now.Subtract(lastSessionUpdateTime).TotalSeconds;
@@ -255,6 +260,11 @@ protected async Task EndSessionInternal()
}
UtilityHelper.CountlyLogging("[CountlyBase] EndSessionInternal'");
+ if (moduleServerConfig != null && !moduleServerConfig.GetSessionTrackingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] EndSessionInternal, session tracking disabled by server config, ignoring");
+ return;
+ }
+
//report the duration of current view
reportViewDuration();
@@ -302,6 +312,8 @@ internal async Task Upload()
return true;
}
+ RemoveTooOldRequests();
+
bool success = false;
// Iterative drain with a no-progress guard.The guard stops once a pass makes no progress, so no stuck queue can loop forever.
@@ -717,6 +729,19 @@ protected async Task RecordEventInternal(string Key, int Count, double? Su
UtilityHelper.CountlyLogging("[CountlyBase] RecordEventInternal, tracking disabled by server config, ignoring event");
return true;
}
+ if (!IsReservedEventKey(Key) && moduleServerConfig != null && !moduleServerConfig.GetCustomEventTrackingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] RecordEventInternal, custom event tracking disabled by server config, ignoring event");
+ return true;
+ }
+ bool journeyTrigger = false;
+ if (!IsReservedEventKey(Key) && moduleServerConfig != null) {
+ if (!moduleServerConfig.IsEventKeyAllowed(Key)) {
+ UtilityHelper.CountlyLogging("[CountlyBase] RecordEventInternal, event key filtered out by server config, ignoring event");
+ return true;
+ }
+ moduleServerConfig.FilterEventSegmentation(Key, Segmentation);
+ journeyTrigger = moduleServerConfig.IsJourneyTriggerEvent(Key);
+ }
if (!Countly.Instance.IsServerURLCorrect(ServerUrl)) { return false; }
if (!CheckConsentOnKey(Key)) { return true; }
@@ -736,11 +761,21 @@ protected async Task RecordEventInternal(string Key, int Count, double? Su
if (saveSuccess) {
//todo rework this
saveSuccess = await Upload();
+ if (saveSuccess && journeyTrigger && moduleContent != null && IsConsentGiven(ConsentFeatures.Content)) {
+ UtilityHelper.CountlyLogging("[CountlyBase] RecordEventInternal, journey trigger event delivered, refreshing content zone");
+ moduleContent.RefreshContentZone();
+ }
}
return saveSuccess;
}
+ /// SDK-internal events ("[CLY]_" prefixed) are not custom events, so the 'cet' gate must not block them.
+ private bool IsReservedEventKey(string key)
+ {
+ return key.StartsWith("[CLY]_", StringComparison.Ordinal);
+ }
+
private bool CheckConsentOnKey(string key)
{
if (key.Equals(VIEW_EVENT_KEY)) {
@@ -948,6 +983,10 @@ public static async Task RecordException(string error, string stackTrace,
internal async Task RecordExceptionInternal(string error, string stackTrace, Dictionary customInfo, bool unhandled)
{
UtilityHelper.CountlyLogging("[CountlyBase] Calling 'RecordException'");
+ if (moduleServerConfig != null && !moduleServerConfig.GetCrashReportingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] RecordException, crash reporting disabled by server config, ignoring exception");
+ return true;
+ }
if (!IsServerURLCorrect(ServerUrl)) { return false; }
if (!IsConsentGiven(ConsentFeatures.Crashes)) { return true; }
@@ -1117,6 +1156,10 @@ protected async void OnUserDetailsChanged()
UserDetails._custom = UtilityHelper.FixSegmentKeysAndValues(UserDetails._custom, Configuration.MaxKeyLength, Configuration.MaxValueSize);
+ if (moduleServerConfig != null) {
+ UserDetails._custom = moduleServerConfig.FilterUserProperties(UserDetails._custom);
+ }
+
UserDetails.isNotificationEnabled = true;
if (!Configuration.backendMode) {
@@ -1141,6 +1184,9 @@ private async Task RecordUserDetails()
string userDetails = RequestHelper.Json(UserDetails);
if (string.IsNullOrEmpty(userDetails) || userDetails.Equals("{}")) {
+ // Nothing to send. Clear the changed flag anyway - leaving it set makes upload
+ // waiters poll forever for a user-details request that will never be created.
+ UserDetails.isChanged = false;
return;
}
@@ -1336,6 +1382,11 @@ public async Task SetLocation(string gpsLocation, string ipAddress = null,
return false;
}
+ if (moduleServerConfig != null && !moduleServerConfig.GetLocationTrackingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] SetLocation, location tracking disabled by server config, ignoring");
+ return true;
+ }
+
if (!IsConsentGiven(ConsentFeatures.Location)) { return true; }
Dictionary requestParams =
@@ -1376,9 +1427,10 @@ protected Dictionary GetLocationParams()
Dictionary locationParams =
new Dictionary();
- /* If location is disabled or no location consent is given,
- the SDK adds an empty location entry to every "begin_session" request. */
- if (Configuration.IsLocationDisabled || !IsConsentGiven(ConsentFeatures.Location)) {
+ /* If location is disabled (by the developer or by server config) or no location consent
+ is given, the SDK adds an empty location entry to every "begin_session" request. */
+ if (Configuration.IsLocationDisabled || !IsConsentGiven(ConsentFeatures.Location)
+ || (moduleServerConfig != null && !moduleServerConfig.GetLocationTrackingEnabled())) {
locationParams.Add("location", string.Empty);
} else {
if (!string.IsNullOrEmpty(Configuration.IPAddress)) {
@@ -1427,6 +1479,10 @@ public async Task DisableLocation()
UtilityHelper.CountlyLogging("[CountlyBase] DisableLocation: SDK must initialized before calling 'DisableLocation'");
return false;
}
+ if (moduleServerConfig != null && !moduleServerConfig.GetLocationTrackingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] DisableLocation, location tracking disabled by server config, ignoring");
+ return true;
+ }
if (!IsConsentGiven(ConsentFeatures.Location)) { return true; }
await SendRequestWithEmptyLocation();
return true;
@@ -1440,6 +1496,54 @@ internal bool IsInitialized()
return false;
}
+ ///
+ /// Drops queued requests older than the server-configured 'dort' limit (in hours).
+ /// The request age is read from its embedded 'timestamp' parameter; 0 disables the feature.
+ ///
+ private void RemoveTooOldRequests()
+ {
+ if (moduleServerConfig == null) { return; }
+ int dropAgeHours = moduleServerConfig.GetDropOldRequestTimeHours();
+ if (dropAgeHours <= 0) { return; }
+
+ long nowMs = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds;
+ long thresholdMs = nowMs - (dropAgeHours * 3600000L);
+
+ lock (sync) {
+ int originalCount = StoredRequests.Count;
+ if (originalCount == 0) { return; }
+ Queue keptRequests = new Queue();
+ foreach (StoredRequest storedRequest in StoredRequests) {
+ if (IsRequestTooOld(storedRequest.Request, thresholdMs)) {
+ UtilityHelper.CountlyLogging("[CountlyBase] RemoveTooOldRequests, dropping request older than [" + dropAgeHours + "] hours: " + storedRequest.Request);
+ } else {
+ keptRequests.Enqueue(storedRequest);
+ }
+ }
+ if (keptRequests.Count != originalCount) {
+ StoredRequests = keptRequests;
+ SaveStoredRequests();
+ }
+ }
+ }
+
+ /// Reads the 'timestamp' parameter (unix ms) out of a request string; unparsable requests are kept.
+ private static bool IsRequestTooOld(string request, long thresholdMs)
+ {
+ if (request == null) { return false; }
+ int index = request.IndexOf("timestamp=", StringComparison.Ordinal);
+ if (index < 0) { return false; }
+ if (index > 0 && request[index - 1] != '&' && request[index - 1] != '?') { return false; }
+
+ int start = index + "timestamp=".Length;
+ int end = start;
+ while (end < request.Length && char.IsDigit(request[end])) { end++; }
+
+ long requestTimestampMs;
+ if (!long.TryParse(request.Substring(start, end - start), out requestTimestampMs)) { return false; }
+ return requestTimestampMs < thresholdMs;
+ }
+
internal async Task AddRequest(string networkRequest, bool isIdMerge = false)
{
Debug.Assert(networkRequest != null);
@@ -1585,6 +1689,7 @@ and no session consent is given. Send empty location as separate request.*/
if (moduleServerConfig != null) {
await moduleServerConfig.FetchServerConfig();
consentRequired = Configuration.consentRequired;
+ TryAutoEnterContentZone();
}
}
@@ -1621,6 +1726,11 @@ public async Task SessionBegin()
return;
}
+ if (moduleServerConfig != null && !moduleServerConfig.GetSessionTrackingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] SessionBegin, session tracking disabled by server config, ignoring");
+ return;
+ }
+
automaticSessionTrackingStarted = true;
startTime = DateTime.Now;
lastSessionUpdateTime = startTime;
@@ -1961,6 +2071,10 @@ public async Task RecordView(string viewName)
return false;
}
+ if (moduleServerConfig != null && !moduleServerConfig.GetViewTrackingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] RecordView, view tracking disabled by server config, ignoring view");
+ return false;
+ }
if (!IsConsentGiven(ConsentFeatures.Views)) {
//if we don't have consent, do nothing
@@ -1997,6 +2111,11 @@ private async void reportViewDuration()
UtilityHelper.CountlyLogging("[CountlyBase] Last view start value is not normal: [" + lastViewStart + "]");
}
+ if (moduleServerConfig != null && !moduleServerConfig.GetViewTrackingEnabled()) {
+ //if view tracking is disabled by server config, do nothing
+ return;
+ }
+
if (!IsConsentGiven(ConsentFeatures.Views)) {
//if we don't have consent, do nothing
return;
@@ -2087,7 +2206,24 @@ public Feedback Feedback()
/// Registers the UI content-display bridge (ungated; no-op if uninitialized).
public void SetContentDisplay(IContentDisplay display)
{
- if (moduleContent != null) { moduleContent.display = display; }
+ if (moduleContent != null) {
+ moduleContent.display = display;
+ // On Windows the display usually arrives after Init, so the server-config driven
+ // "enter content zone after init" (ecz) is retried once the display is available.
+ TryAutoEnterContentZone();
+ }
+ }
+
+ ///
+ /// Enters the content zone automatically when the server config enables 'ecz'.
+ /// Called after init and when a content display gets registered.
+ ///
+ internal void TryAutoEnterContentZone()
+ {
+ if (moduleContent == null || moduleServerConfig == null) { return; }
+ if (!moduleServerConfig.GetEnterContentZoneEnabled()) { return; }
+ if (!IsConsentGiven(ConsentFeatures.Content)) { return; }
+ moduleContent.EnterContentZone();
}
///
diff --git a/countlyCommon/countlyCommon/Modules/ModuleContent.cs b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
index 1429df7..56d02d9 100644
--- a/countlyCommon/countlyCommon/Modules/ModuleContent.cs
+++ b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
@@ -72,6 +72,11 @@ public void Dispose()
public void RefreshContentZone()
{
+ ModuleServerConfig serverConfig = Countly.Instance.moduleServerConfig;
+ if (serverConfig != null && !serverConfig.GetRefreshContentZoneEnabled()) {
+ UtilityHelper.CountlyLogging("[ModuleContent] RefreshContentZone, disabled by server config, ignoring");
+ return;
+ }
string[] categories = _categories; // capture before ExitContentZone so the filter survives the refresh
ExitContentZone();
EnterContentZone(categories);
diff --git a/countlyCommon/countlyCommon/Modules/ModuleServerConfig.cs b/countlyCommon/countlyCommon/Modules/ModuleServerConfig.cs
index a06a932..5be4b9b 100644
--- a/countlyCommon/countlyCommon/Modules/ModuleServerConfig.cs
+++ b/countlyCommon/countlyCommon/Modules/ModuleServerConfig.cs
@@ -16,22 +16,63 @@ internal interface IServerConfigProvider
{
bool GetTrackingEnabled();
bool GetNetworkingEnabled();
+ bool GetSessionTrackingEnabled();
+ bool GetViewTrackingEnabled();
+ bool GetCustomEventTrackingEnabled();
+ bool GetCrashReportingEnabled();
+ bool GetLocationTrackingEnabled();
+ bool GetEnterContentZoneEnabled();
+ bool GetRefreshContentZoneEnabled();
}
internal class ModuleServerConfig : IServerConfigProvider
{
internal const string serverConfigFilename = "server_config.xml";
- private static readonly string[] BoolKeys = { "tracking", "networking", "cr", "log" };
- private static readonly string[] PositiveIntKeys = { "rqs", "eqs", "sui", "scui", "lkl", "lvs", "lsv", "lbc", "ltlpt", "ltl" };
+ private static readonly string[] BoolKeys = { "tracking", "networking", "cr", "log", "st", "vt", "cet", "crt", "lt", "ecz", "rcz" };
+ private static readonly string[] PositiveIntKeys = { "rqs", "eqs", "sui", "scui", "lkl", "lvs", "lsv", "lbc", "ltlpt", "ltl", "upcl" };
+ // Listing filters (string arrays) + journey trigger events.
+ private static readonly string[] StringArrayKeys = { "eb", "ew", "upb", "upw", "sb", "sw", "jte" };
+ // Per-event segmentation filters (objects mapping event name -> array of segmentation keys).
+ private static readonly string[] ObjectKeys = { "esb", "esw" };
+ // If a fetch carries one filter type, the stored opposite type is evicted per category-pairing rules.
+ private static readonly string[] WhitelistKeys = { "ew", "upw", "sw", "esw" };
+ private static readonly string[] BlacklistKeys = { "eb", "upb", "sb", "esb" };
+ // Content zone poll interval; values below 16 seconds are rejected (matches the config setter).
+ private const string ContentZoneIntervalKey = "czi";
+ private const int ContentZoneIntervalMin = 16;
+ // Drop-old-request time in hours; 0 disables the feature.
+ private const string DropOldRequestTimeKey = "dort";
private readonly RequestHelper requestHelper;
private readonly string ServerUrl;
private readonly object configLock = new object();
- // Effective gate state (defaults: allowed).
+ // Effective gate state (defaults: allowed; only enter-content-zone defaults to off).
private bool currentTracking = true;
private bool currentNetworking = true;
+ private bool currentSessionTracking = true;
+ private bool currentViewTracking = true;
+ private bool currentCustomEventTracking = true;
+ private bool currentCrashReporting = true;
+ private bool currentLocationTracking = true;
+ private bool currentEnterContentZone = false;
+ private bool currentRefreshContentZone = true;
+
+ // Listing filters: empty set = no filtering. Each is a blacklist unless its whitelist flag is set.
+ private HashSet eventFilter = new HashSet();
+ private bool eventFilterIsWhitelist = false;
+ private HashSet userPropertyFilter = new HashSet();
+ private bool userPropertyFilterIsWhitelist = false;
+ private HashSet segmentationFilter = new HashSet();
+ private bool segmentationFilterIsWhitelist = false;
+ private Dictionary> eventSegmentationFilter = new Dictionary>();
+ private bool eventSegmentationFilterIsWhitelist = false;
+ private HashSet journeyTriggerEvents = new HashSet();
+
+ // Requests older than this many hours are dropped from the queue (0 = disabled).
+ private int currentDropOldRequestTime = 0;
+ private int currentUserPropertyCacheLimit = 100;
// SBS self-refresh interval in HOURS (server-tunable via 'scui'). Default 4h.
private int currentServerConfigUpdateInterval = 4;
@@ -53,6 +94,85 @@ public ModuleServerConfig(RequestHelper requestHelper, string serverUrl)
public bool GetNetworkingEnabled() { lock (configLock) { return currentNetworking; } }
+ public bool GetSessionTrackingEnabled() { lock (configLock) { return currentSessionTracking; } }
+
+ public bool GetViewTrackingEnabled() { lock (configLock) { return currentViewTracking; } }
+
+ public bool GetCustomEventTrackingEnabled() { lock (configLock) { return currentCustomEventTracking; } }
+
+ public bool GetCrashReportingEnabled() { lock (configLock) { return currentCrashReporting; } }
+
+ public bool GetLocationTrackingEnabled() { lock (configLock) { return currentLocationTracking; } }
+
+ public bool GetEnterContentZoneEnabled() { lock (configLock) { return currentEnterContentZone; } }
+
+ public bool GetRefreshContentZoneEnabled() { lock (configLock) { return currentRefreshContentZone; } }
+
+ internal int GetDropOldRequestTimeHours() { lock (configLock) { return currentDropOldRequestTime; } }
+
+ internal int GetUserPropertyCacheLimit() { lock (configLock) { return currentUserPropertyCacheLimit; } }
+
+ internal bool IsEventKeyAllowed(string key) { lock (configLock) { return IsAllowedByFilter(key, eventFilter, eventFilterIsWhitelist); } }
+
+ internal bool IsUserPropertyAllowed(string key) { lock (configLock) { return IsAllowedByFilter(key, userPropertyFilter, userPropertyFilterIsWhitelist); } }
+
+ internal bool IsJourneyTriggerEvent(string key) { lock (configLock) { return journeyTriggerEvents.Contains(key); } }
+
+ /// Empty filter = everything allowed; whitelist keeps listed keys, blacklist drops them.
+ private static bool IsAllowedByFilter(string key, HashSet filter, bool isWhitelist)
+ {
+ if (filter.Count == 0) { return true; }
+ bool contains = filter.Contains(key);
+ return isWhitelist ? contains : !contains;
+ }
+
+ /// Applies the global (sb/sw) then the per-event (esb/esw) segmentation filters in place.
+ internal void FilterEventSegmentation(string eventKey, Segmentation segmentation)
+ {
+ if (segmentation == null || segmentation.segmentation == null || segmentation.segmentation.Count == 0) { return; }
+ lock (configLock) {
+ if (segmentationFilter.Count > 0) {
+ RemoveFilteredItems(segmentation, segmentationFilter, segmentationFilterIsWhitelist);
+ }
+ HashSet eventRules;
+ if (eventSegmentationFilter.TryGetValue(eventKey, out eventRules) && eventRules.Count > 0) {
+ RemoveFilteredItems(segmentation, eventRules, eventSegmentationFilterIsWhitelist);
+ }
+ }
+ }
+
+ private static void RemoveFilteredItems(Segmentation segmentation, HashSet filter, bool isWhitelist)
+ {
+ List items = segmentation.segmentation;
+ for (int i = items.Count - 1; i >= 0; i--) {
+ if (!IsAllowedByFilter(items[i].Key, filter, isWhitelist)) {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] FilterEventSegmentation, segmentation key filtered out by server config: " + items[i].Key);
+ items.RemoveAt(i);
+ }
+ }
+ }
+
+ /// Applies the user property filter (upb/upw) and the cache limit (upcl) to custom user properties.
+ internal Dictionary FilterUserProperties(Dictionary properties)
+ {
+ if (properties == null) { return null; }
+ lock (configLock) {
+ Dictionary result = new Dictionary();
+ foreach (KeyValuePair kv in properties) {
+ if (!IsAllowedByFilter(kv.Key, userPropertyFilter, userPropertyFilterIsWhitelist)) {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] FilterUserProperties, user property filtered out by server config: " + kv.Key);
+ continue;
+ }
+ if (result.Count >= currentUserPropertyCacheLimit) {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] FilterUserProperties, user property cache limit [" + currentUserPropertyCacheLimit + "] reached, dropping: " + kv.Key, LogLevel.WARNING);
+ continue;
+ }
+ result[kv.Key] = kv.Value;
+ }
+ return result;
+ }
+ }
+
///
/// Synchronously loads stored settings, parses developer-provided settings, and applies
/// both (provided first, stored second) to the effective configuration. No network I/O.
@@ -92,6 +212,20 @@ private void ApplyConfigMap(JObject c)
lock (configLock) {
if (c["tracking"] != null) { currentTracking = c["tracking"].Value(); }
if (c["networking"] != null) { currentNetworking = c["networking"].Value(); }
+ if (c["st"] != null) { currentSessionTracking = c["st"].Value(); }
+ if (c["vt"] != null) { currentViewTracking = c["vt"].Value(); }
+ if (c["cet"] != null) { currentCustomEventTracking = c["cet"].Value(); }
+ if (c["crt"] != null) { currentCrashReporting = c["crt"].Value(); }
+ if (c["lt"] != null) { currentLocationTracking = c["lt"].Value(); }
+ if (c["ecz"] != null) { currentEnterContentZone = c["ecz"].Value(); }
+ if (c["rcz"] != null) { currentRefreshContentZone = c["rcz"].Value(); }
+ if (c[DropOldRequestTimeKey] != null) { currentDropOldRequestTime = c[DropOldRequestTimeKey].Value(); }
+ if (c["upcl"] != null) { currentUserPropertyCacheLimit = c["upcl"].Value(); }
+ if (c["eb"] != null) { eventFilter = ExtractStringSet(c["eb"]); eventFilterIsWhitelist = false; } else if (c["ew"] != null) { eventFilter = ExtractStringSet(c["ew"]); eventFilterIsWhitelist = true; }
+ if (c["upb"] != null) { userPropertyFilter = ExtractStringSet(c["upb"]); userPropertyFilterIsWhitelist = false; } else if (c["upw"] != null) { userPropertyFilter = ExtractStringSet(c["upw"]); userPropertyFilterIsWhitelist = true; }
+ if (c["sb"] != null) { segmentationFilter = ExtractStringSet(c["sb"]); segmentationFilterIsWhitelist = false; } else if (c["sw"] != null) { segmentationFilter = ExtractStringSet(c["sw"]); segmentationFilterIsWhitelist = true; }
+ if (c["esb"] != null) { eventSegmentationFilter = ExtractStringSetMap(c["esb"]); eventSegmentationFilterIsWhitelist = false; } else if (c["esw"] != null) { eventSegmentationFilter = ExtractStringSetMap(c["esw"]); eventSegmentationFilterIsWhitelist = true; }
+ if (c["jte"] != null) { journeyTriggerEvents = ExtractStringSet(c["jte"]); }
if (c["log"] != null) { Countly.IsLoggingEnabled = c["log"].Value(); }
if (c["scui"] != null) { currentServerConfigUpdateInterval = c["scui"].Value(); }
if (config != null) {
@@ -105,10 +239,31 @@ private void ApplyConfigMap(JObject c)
if (c["rqs"] != null) { config.RequestQueueMaxSize = c["rqs"].Value(); }
if (c["eqs"] != null) { config.EventQueueThreshold = c["eqs"].Value(); }
if (c["sui"] != null) { config.sessionUpdateInterval = c["sui"].Value(); }
+ if (c[ContentZoneIntervalKey] != null) { config.ContentZoneTimerInterval = c[ContentZoneIntervalKey].Value(); }
}
}
}
+ /// Collects the string entries of a JSON array (non-strings are skipped).
+ private HashSet ExtractStringSet(JToken arr)
+ {
+ HashSet result = new HashSet();
+ foreach (JToken t in (JArray)arr) {
+ if (t.Type == JTokenType.String) { result.Add(t.Value()); }
+ }
+ return result;
+ }
+
+ /// Collects an event-name -> segmentation-key-set map from a JSON object of arrays.
+ private Dictionary> ExtractStringSetMap(JToken obj)
+ {
+ Dictionary> result = new Dictionary>();
+ foreach (KeyValuePair kv in (JObject)obj) {
+ if (kv.Value.Type == JTokenType.Array) { result[kv.Key] = ExtractStringSet(kv.Value); }
+ }
+ return result;
+ }
+
/// Returns the inner config object: the "c" child if present, else the whole object.
private JObject ExtractConfigObject(string json)
{
@@ -149,6 +304,22 @@ private JObject Sanitize(JObject c)
if (val.Type == JTokenType.Integer && val.Value() > 0) { result[key] = val; } else {
UtilityHelper.CountlyLogging("[ModuleServerConfig] Sanitize, dropping invalid positive-int key: " + key, LogLevel.WARNING);
}
+ } else if (key == ContentZoneIntervalKey) {
+ if (val.Type == JTokenType.Integer && val.Value() >= ContentZoneIntervalMin) { result[key] = val; } else {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] Sanitize, dropping invalid content zone interval (must be an integer >= " + ContentZoneIntervalMin + "): " + key, LogLevel.WARNING);
+ }
+ } else if (key == DropOldRequestTimeKey) {
+ if (val.Type == JTokenType.Integer && val.Value() >= 0) { result[key] = val; } else {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] Sanitize, dropping invalid non-negative-int key: " + key, LogLevel.WARNING);
+ }
+ } else if (Array.IndexOf(StringArrayKeys, key) >= 0) {
+ if (val.Type == JTokenType.Array) { result[key] = val; } else {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] Sanitize, dropping non-array key: " + key, LogLevel.WARNING);
+ }
+ } else if (Array.IndexOf(ObjectKeys, key) >= 0) {
+ if (val.Type == JTokenType.Object) { result[key] = val; } else {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] Sanitize, dropping non-object key: " + key, LogLevel.WARNING);
+ }
} else {
UtilityHelper.CountlyLogging("[ModuleServerConfig] Sanitize, dropping unsupported key: " + key, LogLevel.WARNING);
}
@@ -207,6 +378,7 @@ private void MergeAndPersist(JObject env)
storedFullConfig["v"] = env["v"];
storedFullConfig["t"] = env["t"];
if (storedConfig == null) { storedConfig = new JObject(); }
+ EvictOppositeFilterKeys(incoming, storedConfig);
foreach (KeyValuePair kv in incoming) {
storedConfig[kv.Key] = kv.Value;
}
@@ -215,6 +387,19 @@ private void MergeAndPersist(JObject env)
PersistStoredConfig();
}
+ ///
+ /// When a fetch carries filters of one type, the stored filters of the opposite type are
+ /// removed so a server-side switch between blacklists and whitelists fully replaces the old set.
+ ///
+ private void EvictOppositeFilterKeys(JObject incoming, JObject stored)
+ {
+ bool hasWhitelist = false, hasBlacklist = false;
+ foreach (string key in WhitelistKeys) { if (incoming[key] != null) { hasWhitelist = true; } }
+ foreach (string key in BlacklistKeys) { if (incoming[key] != null) { hasBlacklist = true; } }
+ if (hasWhitelist) { foreach (string key in BlacklistKeys) { stored.Remove(key); } }
+ if (hasBlacklist) { foreach (string key in WhitelistKeys) { stored.Remove(key); } }
+ }
+
private void PersistStoredConfig()
{
string json;