diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f9157b..7342f45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,8 @@
## 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).
+ * 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:
* "DownloadKeys" for fetching RC values from server
* "GetValues" for accessing all RC values
diff --git a/countlyCommon/TestingRelated/ModuleServerConfigTests.cs b/countlyCommon/TestingRelated/ModuleServerConfigTests.cs
new file mode 100644
index 0000000..2be0316
--- /dev/null
+++ b/countlyCommon/TestingRelated/ModuleServerConfigTests.cs
@@ -0,0 +1,227 @@
+using System;
+using System.Collections.Generic;
+using CountlySDK;
+using CountlySDK.CountlyCommon;
+using CountlySDK.Entities;
+using CountlySDK.Helpers;
+using Xunit;
+
+namespace TestProject_common
+{
+ public class ModuleServerConfigTests : IDisposable
+ {
+ public ModuleServerConfigTests()
+ {
+ CountlyImpl.SetPCLStorageIfNeeded();
+ Countly.Halt();
+ TestHelper.CleanDataFiles();
+ }
+
+ public void Dispose()
+ {
+ }
+
+ [Fact]
+ /// Config setters chain fluently and store their values.
+ public void ConfigApi_SettersChainAndStore()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ CountlyConfig returned = (CountlyConfig)cc
+ .SetSDKBehaviorSettings("{\"c\":{\"tracking\":false}}")
+ .DisableSDKBehaviorSettingsUpdates();
+
+ Assert.Same(cc, returned);
+ Assert.Equal("{\"c\":{\"tracking\":false}}", cc.providedSdkBehaviorSettings);
+ Assert.True(cc.sdkBehaviorSettingsUpdatesDisabled);
+ }
+
+ [Fact]
+ /// Developer-provided settings are applied to Configuration at init.
+ public void ProvidedSettings_AppliedAtInit()
+ {
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"lkl\":40,\"rqs\":50,\"log\":true,\"tracking\":false}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.Equal(40, Countly.Instance.Configuration.MaxKeyLength);
+ Assert.Equal(50, Countly.Instance.Configuration.RequestQueueMaxSize);
+ Assert.True(Countly.IsLoggingEnabled);
+ Assert.False(Countly.Instance.moduleServerConfig.GetTrackingEnabled());
+ }
+
+ [Fact]
+ /// Stored (server) settings take precedence over developer-provided settings.
+ public void StoredSettings_OverrideProvided()
+ {
+ Storage.Instance.SaveToFile(
+ ModuleServerConfig.serverConfigFilename,
+ new ServerConfigEntity { Json = "{\"v\":1,\"t\":1,\"c\":{\"lkl\":77}}" }).Wait();
+
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.SetSDKBehaviorSettings("{\"c\":{\"lkl\":40}}");
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.Equal(77, Countly.Instance.Configuration.MaxKeyLength);
+ }
+
+ [Fact]
+ /// Server 'cr' can turn consent enforcement ON.
+ public void ConsentRequired_ServerCanEnable()
+ {
+ Storage.Instance.SaveToFile(
+ ModuleServerConfig.serverConfigFilename,
+ new ServerConfigEntity { Json = "{\"v\":1,\"t\":1,\"c\":{\"cr\":true}}" }).Wait();
+
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.True(Countly.Instance.Configuration.consentRequired);
+ }
+
+ [Fact]
+ /// Server 'cr=false' can NOT turn OFF developer-required consent (enable-only).
+ public void ConsentRequired_ServerCannotDisable()
+ {
+ Storage.Instance.SaveToFile(
+ ModuleServerConfig.serverConfigFilename,
+ new ServerConfigEntity { Json = "{\"v\":1,\"t\":1,\"c\":{\"cr\":false}}" }).Wait();
+
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.consentRequired = true;
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.True(Countly.Instance.Configuration.consentRequired);
+ }
+
+ [Fact]
+ /// A valid SBS response is fetched via method=sc on /o/sdk, applied and persisted.
+ public void FetchServerConfig_ValidResponse_AppliedAndRequestShaped()
+ {
+ MockHttpServer server = new MockHttpServer((body) =>
+ body.Contains("method=sc") ? "{\"v\":1,\"t\":1742459739383,\"c\":{\"lkl\":33,\"networking\":false}}" : null);
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.serverUrl = server.Url;
+ Countly.Instance.Init(cc).Wait();
+
+ bool scRequestSent = false;
+ foreach (MockHttpServer.RequestInfo r in server.Requests) {
+ if (r.Body.Contains("method=sc") && r.Path.Contains("/o/sdk")) { scRequestSent = true; }
+ }
+ Assert.True(scRequestSent);
+ Assert.Equal(33, Countly.Instance.Configuration.MaxKeyLength);
+ Assert.False(Countly.Instance.moduleServerConfig.GetNetworkingEnabled());
+ server.Dispose();
+ }
+
+ [Fact]
+ /// An invalid envelope (missing v/t/c) is rejected; defaults are kept.
+ public void FetchServerConfig_InvalidResponse_KeepsDefaults()
+ {
+ MockHttpServer server = new MockHttpServer((body) =>
+ body.Contains("method=sc") ? "{\"garbage\":true}" : null);
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.serverUrl = server.Url;
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.Equal(128, Countly.Instance.Configuration.MaxKeyLength);
+ Assert.True(Countly.Instance.moduleServerConfig.GetNetworkingEnabled());
+ server.Dispose();
+ }
+
+ [Fact]
+ /// DisableSDKBehaviorSettingsUpdates skips the fetch yet still applies stored settings.
+ public void DisableUpdates_SkipsFetch_ButAppliesStored()
+ {
+ Storage.Instance.SaveToFile(
+ ModuleServerConfig.serverConfigFilename,
+ new ServerConfigEntity { Json = "{\"v\":1,\"t\":1,\"c\":{\"lkl\":55}}" }).Wait();
+ int scCalls = 0;
+ MockHttpServer server = new MockHttpServer((body) => {
+ if (body.Contains("method=sc")) { scCalls++; return "{\"v\":1,\"t\":2,\"c\":{\"lkl\":99}}"; }
+ return null;
+ });
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.serverUrl = server.Url;
+ cc.DisableSDKBehaviorSettingsUpdates();
+ Countly.Instance.Init(cc).Wait();
+
+ Assert.Equal(0, scCalls);
+ Assert.Equal(55, Countly.Instance.Configuration.MaxKeyLength);
+ server.Dispose();
+ }
+
+ [Fact]
+ /// Successive fetches merge per-key rather than replacing the stored config.
+ public void FetchServerConfig_MergesAcrossFetches()
+ {
+ string response = "{\"v\":1,\"t\":1,\"c\":{\"lkl\":33}}";
+ MockHttpServer server = new MockHttpServer((body) => body.Contains("method=sc") ? response : null);
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.serverUrl = server.Url;
+ Countly.Instance.Init(cc).Wait();
+ Assert.Equal(33, Countly.Instance.Configuration.MaxKeyLength);
+
+ response = "{\"v\":1,\"t\":2,\"c\":{\"lvs\":44}}";
+ Countly.Instance.moduleServerConfig.FetchServerConfig().Wait();
+ Assert.Equal(33, Countly.Instance.Configuration.MaxKeyLength); // retained via merge
+ Assert.Equal(44, Countly.Instance.Configuration.MaxValueSize); // newly added
+ server.Dispose();
+ }
+
+ [Fact]
+ /// tracking=false blocks event capture (event never enters the queue).
+ public void Tracking_Disabled_BlocksEventCapture()
+ {
+ MockHttpServer server = new MockHttpServer((body) =>
+ body.Contains("method=sc") ? "{\"v\":1,\"t\":1,\"c\":{\"tracking\":false}}" : null);
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.serverUrl = server.Url;
+ Countly.Instance.Init(cc).Wait();
+ Assert.False(Countly.Instance.moduleServerConfig.GetTrackingEnabled());
+
+ Countly.RecordEvent("test_event", 1, null, null).Wait();
+ Assert.Empty(Countly.Instance.Events);
+ server.Dispose();
+ }
+
+ [Fact]
+ /// networking=false stops sending but data stays queued locally.
+ public void Networking_Disabled_BlocksUpload_ButQueues()
+ {
+ MockHttpServer server = new MockHttpServer((body) =>
+ body.Contains("method=sc") ? "{\"v\":1,\"t\":1,\"c\":{\"networking\":false}}" : null);
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.serverUrl = server.Url;
+ Countly.Instance.Init(cc).Wait();
+ Assert.False(Countly.Instance.moduleServerConfig.GetNetworkingEnabled());
+
+ Countly.RecordEvent("e", 1, null, null).Wait();
+ Countly.Instance.Upload().Wait();
+ Assert.NotEmpty(Countly.Instance.Events); // captured but not uploaded
+ server.Dispose();
+ }
+
+ [Fact]
+ /// A device-ID change triggers an additional SBS fetch.
+ public void ChangeDeviceId_TriggersRefetch()
+ {
+ int scCalls = 0;
+ MockHttpServer server = new MockHttpServer((body) => {
+ if (body.Contains("method=sc")) { scCalls++; return "{\"v\":1,\"t\":1,\"c\":{\"lkl\":50}}"; }
+ return null;
+ });
+ CountlyConfig cc = TestHelper.GetConfig();
+ cc.serverUrl = server.Url;
+ Countly.Instance.Init(cc).Wait();
+ Assert.Equal(1, scCalls);
+
+ Countly.Instance.ChangeDeviceId("new_device_id").Wait();
+ Assert.Equal(2, scCalls);
+ server.Dispose();
+ }
+ }
+}
diff --git a/countlyCommon/TestingRelated/SessionTests.cs b/countlyCommon/TestingRelated/SessionTests.cs
index 18b9276..d4ca590 100644
--- a/countlyCommon/TestingRelated/SessionTests.cs
+++ b/countlyCommon/TestingRelated/SessionTests.cs
@@ -18,7 +18,7 @@ public class SessionTests : IDisposable
public SessionTests()
{
CountlyImpl.SetPCLStorageIfNeeded();
- Countly.Halt();
+ Countly.Instance.HaltInternal().Wait(); // synchronous teardown: avoid async-void Halt racing with the next Init
TestHelper.CleanDataFiles();
Countly.Instance.deferUpload = true;
}
diff --git a/countlyCommon/TestingRelated/TestHelper.cs b/countlyCommon/TestingRelated/TestHelper.cs
index 19c9cf0..0dcac81 100644
--- a/countlyCommon/TestingRelated/TestHelper.cs
+++ b/countlyCommon/TestingRelated/TestHelper.cs
@@ -11,6 +11,7 @@
using System.Threading.Tasks;
using System.Web.UI.WebControls;
using CountlySDK;
+using CountlySDK.CountlyCommon;
using CountlySDK.CountlyCommon.Entities;
using CountlySDK.Entities;
using CountlySDK.Entities.EntityBase;
@@ -327,6 +328,17 @@ public static async void CleanDataFiles()
Storage.Instance.DeleteFile(Countly.userDetailsFilename).Wait();
Storage.Instance.DeleteFile(Countly.storedRequestsFilename).Wait();
Storage.Instance.DeleteFile(Device.deviceFilename).Wait();
+ Storage.Instance.DeleteFile(ModuleServerConfig.serverConfigFilename).Wait();
+ }
+
+ ///
+ /// Returns the captured requests excluding the SDK Behavior Settings (Server Config)
+ /// fetch (method=sc). That request is on by default and sent off-queue at init, so tests
+ /// asserting on the normal request flow filter it out.
+ ///
+ public static List NonServerConfigRequests(MockHttpServer server)
+ {
+ return server.Requests.Where(r => r.Body == null || !r.Body.Contains("method=sc")).ToList();
}
public static string DCSSerialize(object obj)
diff --git a/countlyCommon/TestingRelated/UserDetailsTests.cs b/countlyCommon/TestingRelated/UserDetailsTests.cs
index b2824d4..27b9b12 100644
--- a/countlyCommon/TestingRelated/UserDetailsTests.cs
+++ b/countlyCommon/TestingRelated/UserDetailsTests.cs
@@ -14,7 +14,7 @@ public class UserDetailsTests : IDisposable
public UserDetailsTests()
{
CountlyImpl.SetPCLStorageIfNeeded();
- Countly.Halt();
+ Countly.Instance.HaltInternal().Wait(); // synchronous teardown: avoid async-void Halt racing with the next Init
TestHelper.CleanDataFiles();
Countly.Instance.deferUpload = false;
}
@@ -107,14 +107,15 @@ public void SetUserDetails_SessionTriggers()
Countly.Instance.SessionEnd().Wait();
System.Threading.Thread.Sleep(2000);
- Assert.Equal(6, server.Requests.Count);
+ var reqs = TestHelper.NonServerConfigRequests(server);
+ Assert.Equal(6, reqs.Count);
- TestHelper.ValidateRequest(server.Requests[0].Params, TestHelper.Dict("user_details", TestHelper.Json("custom", TestHelper.Dict("Papa", "Black_1"))));
- TestHelper.ValidateRequest(server.Requests[1].Params, TestHelper.Dict("begin_session", "1", "metrics", TestHelper.GetSessionMetrics()));
- TestHelper.ValidateRequest(server.Requests[2].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John")));
- TestHelper.ValidateRequest(server.Requests[3].Params, TestHelper.Dict("session_duration", 2));
- TestHelper.ValidateRequest(server.Requests[4].Params, TestHelper.Dict("user_details", TestHelper.Json("email", "Doe@doe.com")));
- TestHelper.ValidateRequest(server.Requests[5].Params, TestHelper.Dict("end_session", "1", "session_duration", 2));
+ TestHelper.ValidateRequest(reqs[0].Params, TestHelper.Dict("user_details", TestHelper.Json("custom", TestHelper.Dict("Papa", "Black_1"))));
+ TestHelper.ValidateRequest(reqs[1].Params, TestHelper.Dict("begin_session", "1", "metrics", TestHelper.GetSessionMetrics()));
+ TestHelper.ValidateRequest(reqs[2].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John")));
+ TestHelper.ValidateRequest(reqs[3].Params, TestHelper.Dict("session_duration", 2));
+ TestHelper.ValidateRequest(reqs[4].Params, TestHelper.Dict("user_details", TestHelper.Json("email", "Doe@doe.com")));
+ TestHelper.ValidateRequest(reqs[5].Params, TestHelper.Dict("end_session", "1", "session_duration", 2));
server.Dispose();
}
@@ -146,13 +147,14 @@ public void SetUserDetails_SessionTriggers_Disable()
Countly.UserDetails.Save();
System.Threading.Thread.Sleep(200);
- Assert.Equal(4, server.Requests.Count);
+ var reqs = TestHelper.NonServerConfigRequests(server);
+ Assert.Equal(4, reqs.Count);
- TestHelper.ValidateRequest(server.Requests[0].Params, TestHelper.Dict("begin_session", "1", "metrics", TestHelper.GetSessionMetrics()));
- TestHelper.ValidateRequest(server.Requests[1].Params, TestHelper.Dict("session_duration", 2));
- TestHelper.ValidateRequest(server.Requests[2].Params, TestHelper.Dict("end_session", "1", "session_duration", 2));
+ TestHelper.ValidateRequest(reqs[0].Params, TestHelper.Dict("begin_session", "1", "metrics", TestHelper.GetSessionMetrics()));
+ TestHelper.ValidateRequest(reqs[1].Params, TestHelper.Dict("session_duration", 2));
+ TestHelper.ValidateRequest(reqs[2].Params, TestHelper.Dict("end_session", "1", "session_duration", 2));
- TestHelper.ValidateRequest(server.Requests[3].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John", "email", "Doe@doe.com", "custom", TestHelper.Dict("Papa", "Black_1"))));
+ TestHelper.ValidateRequest(reqs[3].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John", "email", "Doe@doe.com", "custom", TestHelper.Dict("Papa", "Black_1"))));
server.Dispose();
}
@@ -184,14 +186,15 @@ public void SetUserDetails_SessionTriggers_Disable_ManualSaveDisabled()
System.Threading.Thread.Sleep(2000);
Countly.UserDetails.Save(); // will not work
- Assert.Equal(6, server.Requests.Count);
+ var reqs = TestHelper.NonServerConfigRequests(server);
+ Assert.Equal(6, reqs.Count);
- TestHelper.ValidateRequest(server.Requests[0].Params, TestHelper.Dict("user_details", TestHelper.Json("custom", TestHelper.Dict("Papa", "Black_1"))));
- TestHelper.ValidateRequest(server.Requests[1].Params, TestHelper.Dict("begin_session", "1", "metrics", TestHelper.GetSessionMetrics()));
- TestHelper.ValidateRequest(server.Requests[2].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John", "custom", TestHelper.Dict("Papa", "Black_1"))));
- TestHelper.ValidateRequest(server.Requests[3].Params, TestHelper.Dict("session_duration", 2));
- TestHelper.ValidateRequest(server.Requests[4].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John", "email", "Doe@doe.com", "custom", TestHelper.Dict("Papa", "Black_1"))));
- TestHelper.ValidateRequest(server.Requests[5].Params, TestHelper.Dict("end_session", "1", "session_duration", 2));
+ TestHelper.ValidateRequest(reqs[0].Params, TestHelper.Dict("user_details", TestHelper.Json("custom", TestHelper.Dict("Papa", "Black_1"))));
+ TestHelper.ValidateRequest(reqs[1].Params, TestHelper.Dict("begin_session", "1", "metrics", TestHelper.GetSessionMetrics()));
+ TestHelper.ValidateRequest(reqs[2].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John", "custom", TestHelper.Dict("Papa", "Black_1"))));
+ TestHelper.ValidateRequest(reqs[3].Params, TestHelper.Dict("session_duration", 2));
+ TestHelper.ValidateRequest(reqs[4].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John", "email", "Doe@doe.com", "custom", TestHelper.Dict("Papa", "Black_1"))));
+ TestHelper.ValidateRequest(reqs[5].Params, TestHelper.Dict("end_session", "1", "session_duration", 2));
server.Dispose();
}
@@ -224,18 +227,19 @@ public void SetUserDetails_SessionEventsTriggers()
System.Threading.Thread.Sleep(2000);
Countly.UserDetails.Save(); // will not work
- Assert.Equal(7, server.Requests.Count);
+ var reqs = TestHelper.NonServerConfigRequests(server);
+ Assert.Equal(7, reqs.Count);
- TestHelper.ValidateRequest(server.Requests[0].Params, TestHelper.Dict("user_details", TestHelper.Json("custom", TestHelper.Dict("Papa", "Black_1"))));
- TestHelper.ValidateRequest(server.Requests[1].Params, TestHelper.Dict("begin_session", "1", "metrics", TestHelper.GetSessionMetrics()));
- Assert.Contains("Test", server.Requests[2].Params["events"]);
- TestHelper.ValidateRequest(server.Requests[3].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John")));
- Assert.Contains("Test1", server.Requests[4].Params["events"]);
- TestHelper.ValidateRequest(server.Requests[5].Params, TestHelper.Dict("user_details", TestHelper.Json("email", "Doe@doe.com")));
+ TestHelper.ValidateRequest(reqs[0].Params, TestHelper.Dict("user_details", TestHelper.Json("custom", TestHelper.Dict("Papa", "Black_1"))));
+ TestHelper.ValidateRequest(reqs[1].Params, TestHelper.Dict("begin_session", "1", "metrics", TestHelper.GetSessionMetrics()));
+ Assert.Contains("Test", reqs[2].Params["events"]);
+ TestHelper.ValidateRequest(reqs[3].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John")));
+ Assert.Contains("Test1", reqs[4].Params["events"]);
+ TestHelper.ValidateRequest(reqs[5].Params, TestHelper.Dict("user_details", TestHelper.Json("email", "Doe@doe.com")));
// session_duration here is wall-clock derived (~2.4s of sleeps), so it rounds to 2 or 3
// depending on machine/CI speed. Validate its presence with a tolerant range instead of
// an exact value to avoid timing flakiness.
- TestHelper.ValidateRequest(server.Requests[6].Params, TestHelper.Dict("end_session", "1", "session_duration", 3),
+ TestHelper.ValidateRequest(reqs[6].Params, TestHelper.Dict("end_session", "1", "session_duration", 3),
new Dictionary> {
{ "session_duration", (actual, _) => Assert.True(int.Parse(actual) >= 2 && int.Parse(actual) <= 4, "session_duration was " + actual) }
});
diff --git a/countlyCommon/countlyCommon/CountlyBase.cs b/countlyCommon/countlyCommon/CountlyBase.cs
index 83bd4fd..502d1d7 100644
--- a/countlyCommon/countlyCommon/CountlyBase.cs
+++ b/countlyCommon/countlyCommon/CountlyBase.cs
@@ -62,6 +62,7 @@ public enum LogLevel { VERBOSE, DEBUG, INFO, WARNING, ERROR };
internal ModuleRemoteConfig moduleRemoteConfig;
internal ModuleFeedback moduleFeedback;
internal ModuleContent moduleContent;
+ internal ModuleServerConfig moduleServerConfig;
public abstract string sdkName();
@@ -295,6 +296,12 @@ protected async Task EndSessionInternal()
internal async Task Upload()
{
UtilityHelper.CountlyLogging("[CountlyBase] Calling 'Upload'");
+
+ if (moduleServerConfig != null && !moduleServerConfig.GetNetworkingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] Upload, networking disabled by server config, skipping upload");
+ return true;
+ }
+
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.
@@ -706,6 +713,10 @@ public static Task RecordEvent(string Key, int Count, double? Sum, double?
protected async Task RecordEventInternal(string Key, int Count, double? Sum, double? Duration, Segmentation Segmentation)
{
UtilityHelper.CountlyLogging("[CountlyBase] Calling 'RecordEventInternal'");
+ if (moduleServerConfig != null && !moduleServerConfig.GetTrackingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] RecordEventInternal, tracking disabled by server config, ignoring event");
+ return true;
+ }
if (!Countly.Instance.IsServerURLCorrect(ServerUrl)) { return false; }
if (!CheckConsentOnKey(Key)) { return true; }
@@ -1222,6 +1233,8 @@ internal async Task HaltInternal(bool clearStorage = true)
moduleFeedback = null;
if (moduleContent != null) { moduleContent.ExitContentZone(); } // stop the poll timer
moduleContent = null;
+ if (moduleServerConfig != null) { moduleServerConfig.StopTimer(); }
+ moduleServerConfig = null;
}
if (clearStorage) {
await ClearStorage();
@@ -1236,6 +1249,7 @@ protected async Task ClearStorage()
await Storage.Instance.DeleteFile(userDetailsFilename);
await Storage.Instance.DeleteFile(storedRequestsFilename);
await Storage.Instance.DeleteFile(Device.deviceFilename);
+ await Storage.Instance.DeleteFile(ModuleServerConfig.serverConfigFilename);
}
///
@@ -1432,6 +1446,11 @@ internal async Task AddRequest(string networkRequest, bool isIdMerge = false)
if (networkRequest == null) { return; }
+ if (moduleServerConfig != null && !moduleServerConfig.GetTrackingEnabled()) {
+ UtilityHelper.CountlyLogging("[CountlyBase] AddRequest, tracking disabled by server config, ignoring request");
+ return;
+ }
+
lock (sync) {
StoredRequest sr = new StoredRequest(networkRequest, isIdMerge);
if (Configuration.backendMode) {
@@ -1516,6 +1535,11 @@ protected async Task InitBase(CountlyConfig config)
}
}
+ //server config (SDK Behavior Settings): load+apply stored/provided before consent is read
+ moduleServerConfig = new ModuleServerConfig(requestHelper, ServerUrl);
+ moduleServerConfig.InitializeServerConfig(config);
+ sessionUpdateInterval = Configuration.sessionUpdateInterval;
+
//consent related
consentRequired = config.consentRequired;
if (config.givenConsent != null) {
@@ -1557,6 +1581,11 @@ and no session consent is given. Send empty location as separate request.*/
if (Configuration.remoteConfigAutomaticDownloadTriggers) {
await RemoteConfig().DownloadKeys();
}
+
+ if (moduleServerConfig != null) {
+ await moduleServerConfig.FetchServerConfig();
+ consentRequired = Configuration.consentRequired;
+ }
}
public enum DeviceIdType { DeveloperProvided = 0, SDKGenerated = 1 };
@@ -1725,6 +1754,8 @@ public async Task ChangeDeviceId(string newDeviceId, bool serverSideMerge = fals
await AddRequest(request, true);
await Upload();
}
+
+ if (moduleServerConfig != null) { await moduleServerConfig.FetchServerConfig(); }
}
diff --git a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs
index 93f8552..25fc6e1 100644
--- a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs
+++ b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs
@@ -127,6 +127,19 @@ public int ContentZoneTimerInterval {
internal bool autoSendUserDetails = true;
internal bool remoteConfigAutomaticDownloadTriggers = false;
+ //
+ /// Developer-provided SDK Behavior Settings (Server Config) JSON, applied as a
+ /// precedence layer below server-fetched settings. May be a full {v,t,c} envelope
+ /// or a bare config object.
+ ///
+ internal string providedSdkBehaviorSettings = null;
+
+ //
+ /// When true, the SDK does NOT perform the network fetch of SDK Behavior Settings
+ /// (nor its refresh timer). Provided and on-disk settings still load and apply.
+ ///
+ internal bool sdkBehaviorSettingsUpdatesDisabled = false;
+
///
/// Disabled the location tracking on the Countly server
///
@@ -288,5 +301,29 @@ public CountlyConfigBase EnableRemoteConfigAutomaticTriggers()
remoteConfigAutomaticDownloadTriggers = true;
return this;
}
+
+ ///
+ /// Seed developer-provided SDK Behavior Settings (Server Config). Used as a fallback
+ /// source before/instead of stored settings (e.g. first run / offline). Lower precedence
+ /// than settings fetched and stored from the server.
+ ///
+ /// A {v,t,c} envelope or a bare config object as JSON.
+ /// Config for call chaining
+ public CountlyConfigBase SetSDKBehaviorSettings(string sdkBehaviorSettings)
+ {
+ providedSdkBehaviorSettings = sdkBehaviorSettings;
+ return this;
+ }
+
+ ///
+ /// Disables ONLY the network fetch (and refresh timer) of SDK Behavior Settings.
+ /// Developer-provided and on-disk (last-known-good) settings still load and apply.
+ ///
+ /// Config for call chaining
+ public CountlyConfigBase DisableSDKBehaviorSettingsUpdates()
+ {
+ sdkBehaviorSettingsUpdatesDisabled = true;
+ return this;
+ }
}
}
diff --git a/countlyCommon/countlyCommon/Modules/ModuleServerConfig.cs b/countlyCommon/countlyCommon/Modules/ModuleServerConfig.cs
new file mode 100644
index 0000000..a06a932
--- /dev/null
+++ b/countlyCommon/countlyCommon/Modules/ModuleServerConfig.cs
@@ -0,0 +1,255 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using CountlySDK.CountlyCommon.Entities;
+using CountlySDK.CountlyCommon.Helpers;
+using CountlySDK.CountlyCommon.Server.Responses;
+using CountlySDK.Helpers;
+using Newtonsoft.Json.Linq;
+using static CountlySDK.CountlyCommon.CountlyBase;
+
+namespace CountlySDK.CountlyCommon
+{
+ internal interface IServerConfigProvider
+ {
+ bool GetTrackingEnabled();
+ bool GetNetworkingEnabled();
+ }
+
+ 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 readonly RequestHelper requestHelper;
+ private readonly string ServerUrl;
+ private readonly object configLock = new object();
+
+ // Effective gate state (defaults: allowed).
+ private bool currentTracking = true;
+ private bool currentNetworking = true;
+
+ // SBS self-refresh interval in HOURS (server-tunable via 'scui'). Default 4h.
+ private int currentServerConfigUpdateInterval = 4;
+
+ private bool updatesDisabled = false;
+ private JObject providedConfig = null; // sanitized inner config from developer-provided settings
+ private JObject storedConfig = null; // sanitized inner config from the persisted envelope
+ private JObject storedFullConfig = null; // full {v,t,c} envelope (for merge + persist)
+
+ private System.Threading.Timer refreshTimer = null;
+
+ public ModuleServerConfig(RequestHelper requestHelper, string serverUrl)
+ {
+ this.requestHelper = requestHelper;
+ ServerUrl = serverUrl;
+ }
+
+ public bool GetTrackingEnabled() { lock (configLock) { return currentTracking; } }
+
+ public bool GetNetworkingEnabled() { lock (configLock) { return currentNetworking; } }
+
+ ///
+ /// Synchronously loads stored settings, parses developer-provided settings, and applies
+ /// both (provided first, stored second) to the effective configuration. No network I/O.
+ ///
+ internal void InitializeServerConfig(CountlyConfigBase config)
+ {
+ updatesDisabled = config.sdkBehaviorSettingsUpdatesDisabled;
+ providedConfig = Sanitize(ExtractConfigObject(config.providedSdkBehaviorSettings));
+ LoadStoredConfig();
+ ApplyEffectiveConfig();
+ }
+
+ private void LoadStoredConfig()
+ {
+ ServerConfigEntity entity = Storage.Instance.LoadFromFile(serverConfigFilename).Result;
+ if (entity == null || string.IsNullOrEmpty(entity.Json)) { return; }
+ try {
+ JObject env = JObject.Parse(entity.Json);
+ if (!ValidateEnvelope(env)) { return; }
+ storedFullConfig = env;
+ storedConfig = Sanitize((JObject)env["c"]);
+ } catch (Exception ex) {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] LoadStoredConfig, failed to parse stored config: " + ex.Message, LogLevel.WARNING);
+ }
+ }
+
+ private void ApplyEffectiveConfig()
+ {
+ ApplyConfigMap(providedConfig);
+ ApplyConfigMap(storedConfig);
+ }
+
+ private void ApplyConfigMap(JObject c)
+ {
+ if (c == null) { return; }
+ CountlyConfigBase config = Countly.Instance.Configuration;
+ lock (configLock) {
+ if (c["tracking"] != null) { currentTracking = c["tracking"].Value(); }
+ if (c["networking"] != null) { currentNetworking = c["networking"].Value(); }
+ if (c["log"] != null) { Countly.IsLoggingEnabled = c["log"].Value(); }
+ if (c["scui"] != null) { currentServerConfigUpdateInterval = c["scui"].Value(); }
+ if (config != null) {
+ if (c["cr"] != null) { config.consentRequired = config.consentRequired || c["cr"].Value(); }
+ if (c["lkl"] != null) { config.MaxKeyLength = c["lkl"].Value(); }
+ if (c["lvs"] != null) { config.MaxValueSize = c["lvs"].Value(); }
+ if (c["lsv"] != null) { config.MaxSegmentationValues = c["lsv"].Value(); }
+ if (c["lbc"] != null) { config.MaxBreadcrumbCount = c["lbc"].Value(); }
+ if (c["ltlpt"] != null) { config.MaxStackTraceLinesPerThread = c["ltlpt"].Value(); }
+ if (c["ltl"] != null) { config.MaxStackTraceLineLength = c["ltl"].Value(); }
+ 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(); }
+ }
+ }
+ }
+
+ /// Returns the inner config object: the "c" child if present, else the whole object.
+ private JObject ExtractConfigObject(string json)
+ {
+ if (string.IsNullOrEmpty(json)) { return null; }
+ try {
+ JObject parsed = JObject.Parse(json);
+ JToken inner = parsed["c"];
+ if (inner != null && inner.Type == JTokenType.Object) { return (JObject)inner; }
+ return parsed;
+ } catch (Exception ex) {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] ExtractConfigObject, failed to parse: " + ex.Message, LogLevel.WARNING);
+ return null;
+ }
+ }
+
+ /// Envelope is valid iff it has v, t, and a non-empty c object.
+ private bool ValidateEnvelope(JObject env)
+ {
+ if (env == null) { return false; }
+ if (env["v"] == null || env["t"] == null || env["c"] == null) { return false; }
+ JObject c = env["c"] as JObject;
+ return c != null && c.HasValues;
+ }
+
+ /// Keeps only known keys with valid types/ranges; drops (and logs) the rest.
+ private JObject Sanitize(JObject c)
+ {
+ JObject result = new JObject();
+ if (c == null) { return result; }
+ foreach (KeyValuePair kv in c) {
+ string key = kv.Key;
+ JToken val = kv.Value;
+ if (Array.IndexOf(BoolKeys, key) >= 0) {
+ if (val.Type == JTokenType.Boolean) { result[key] = val; } else {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] Sanitize, dropping non-boolean key: " + key, LogLevel.WARNING);
+ }
+ } else if (Array.IndexOf(PositiveIntKeys, key) >= 0) {
+ 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 {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] Sanitize, dropping unsupported key: " + key, LogLevel.WARNING);
+ }
+ }
+ return result;
+ }
+
+ ///
+ /// Network fetch of SDK Behavior Settings (direct, off-queue, /o/sdk?method=sc).
+ /// No-op when updates are disabled. Always (re)starts the refresh timer when enabled,
+ /// so periodic refresh survives a failed fetch.
+ ///
+ internal async Task FetchServerConfig()
+ {
+ if (updatesDisabled) {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] FetchServerConfig, updates disabled, skipping network fetch");
+ return;
+ }
+ await FetchAndApply();
+ StartTimer();
+ }
+
+ private async Task FetchAndApply()
+ {
+ IDictionary scParams = new Dictionary { { "method", "sc" } };
+ RequestResult requestResult = await Api.Instance.SendDirectRequest(ServerUrl, await requestHelper.BuildRequest(scParams));
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] FetchServerConfig, response code: [" + requestResult.responseCode + "], text: [" + requestResult.responseText + "]");
+
+ if (requestResult.responseCode != 200 || requestResult.responseText == null) {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] FetchServerConfig, request failed, keeping current settings", LogLevel.WARNING);
+ return;
+ }
+
+ JObject env;
+ try {
+ env = JObject.Parse(requestResult.responseText);
+ } catch (Exception ex) {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] FetchServerConfig, failed to parse response: " + ex.Message, LogLevel.WARNING);
+ return;
+ }
+
+ if (!ValidateEnvelope(env)) {
+ UtilityHelper.CountlyLogging("[ModuleServerConfig] FetchServerConfig, invalid envelope, keeping current settings", LogLevel.WARNING);
+ return;
+ }
+
+ MergeAndPersist(env);
+ ApplyEffectiveConfig();
+ }
+
+ private void MergeAndPersist(JObject env)
+ {
+ JObject incoming = Sanitize((JObject)env["c"]);
+ lock (configLock) {
+ if (storedFullConfig == null) { storedFullConfig = new JObject(); }
+ storedFullConfig["v"] = env["v"];
+ storedFullConfig["t"] = env["t"];
+ if (storedConfig == null) { storedConfig = new JObject(); }
+ foreach (KeyValuePair kv in incoming) {
+ storedConfig[kv.Key] = kv.Value;
+ }
+ storedFullConfig["c"] = storedConfig;
+ }
+ PersistStoredConfig();
+ }
+
+ private void PersistStoredConfig()
+ {
+ string json;
+ lock (configLock) {
+ if (storedFullConfig == null) { return; }
+ json = storedFullConfig.ToString(Newtonsoft.Json.Formatting.None);
+ }
+ Storage.Instance.SaveToFile(serverConfigFilename, new ServerConfigEntity { Json = json }).Wait();
+ }
+
+ private void StartTimer()
+ {
+ StopTimer();
+ long intervalMs = (long)currentServerConfigUpdateInterval * 60L * 60L * 1000L;
+ refreshTimer = new System.Threading.Timer(OnTimerTick, null, intervalMs, intervalMs);
+ }
+
+ private void OnTimerTick(object state)
+ {
+ FetchServerConfig().Wait();
+ }
+
+ internal void StopTimer()
+ {
+ if (refreshTimer != null) {
+ refreshTimer.Dispose();
+ refreshTimer = null;
+ }
+ }
+ }
+
+ [DataContract]
+ internal class ServerConfigEntity
+ {
+ [DataMember]
+ public string Json;
+ }
+}
diff --git a/netstd/CountlyTest_461/CountlyTest_461.csproj b/netstd/CountlyTest_461/CountlyTest_461.csproj
index f8cd36c..d956635 100644
--- a/netstd/CountlyTest_461/CountlyTest_461.csproj
+++ b/netstd/CountlyTest_461/CountlyTest_461.csproj
@@ -97,6 +97,9 @@
ModuleRemoteConfigTests.cs
+
+ ModuleServerConfigTests.cs
+
RequestTestCases.cs