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