diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9910f564 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + branches: + - staging + +jobs: + feedback-ui-tests: + name: Feedback + UI unit tests (net8.0-windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Run unit tests + run: > + dotnet test ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj + -c Release + -p:SignAssembly=false + -p:DelaySign=false + -p:GenerateDocumentationFile=false + + core-build: + name: Build core (net35 / net45 / netstd) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: microsoft/setup-msbuild@v2 + + - name: Restore + build core targets + shell: pwsh + run: | + nuget restore net35/Countly.sln + nuget restore net45/Countly.sln + msbuild net35/Countly/Countly.csproj /t:Build /p:Configuration=Release /p:SignAssembly=false /p:DelaySign=false /p:GenerateDocumentationFile=false /v:minimal + msbuild net45/Countly/Countly.csproj /t:Build /p:Configuration=Release /p:SignAssembly=false /p:DelaySign=false /p:GenerateDocumentationFile=false /v:minimal + dotnet build netstd/Countly/Countly.csproj -c Release -p:SignAssembly=false -p:DelaySign=false -p:GenerateDocumentationFile=false diff --git a/CHANGELOG.md b/CHANGELOG.md index 2301c9da..6f9157b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ * After initialization finishes. * RemoteConfig consent is given. * After the device ID is changed to a different user. +* Added support for the Feedback Widgets feature (Surveys, NPS, Ratings) accessible through the "Countly.Instance.Feedback()" interface: + * "GetAvailableFeedbackWidgets" for fetching the list of available feedback widgets from the server + * "GetFeedbackWidgetData" for fetching a widget's definition (for building a custom UI) + * "ReportFeedbackWidgetManually" for reporting a widget result, or marking it closed + * "ConstructFeedbackWidgetUrl" for building a widget's display URL + * This feature uses "Feedback" consent (and "StarRating" consent for rating widgets). + * Needs "Countly.UI.WebView2" for displaying feedback widgets on WPF and WinForms. +* Added support for the experimental Content feature accessible through the "Countly.Instance.Content()" interface: + * "EnterContentZone" / "ExitContentZone" for starting and stopping periodic content fetching + * "RefreshContentZone" for forcing an immediate refresh + * "PreviewContent" for fetching and showing a specific content by id + * Added "ContentZoneTimerInterval" and a global content callback to the configuration object + * 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. ## 25.4.3 * ! Minor breaking change ! User properties will now be automatically saved under the following conditions: diff --git a/countlyCommon/TestingRelated/ConsentTests.cs b/countlyCommon/TestingRelated/ConsentTests.cs index 862efdbc..e681d751 100644 --- a/countlyCommon/TestingRelated/ConsentTests.cs +++ b/countlyCommon/TestingRelated/ConsentTests.cs @@ -195,7 +195,7 @@ public void TestConsentRequest() Assert.False(string.IsNullOrEmpty(collection.Get("t"))); JObject consentObj = JObject.Parse(collection.Get("consent")); - Assert.Equal(10, consentObj.Count); + Assert.Equal(11, consentObj.Count); Assert.False(consentObj.GetValue("push").ToObject()); Assert.True(consentObj.GetValue("users").ToObject()); Assert.False(consentObj.GetValue("views").ToObject()); @@ -206,6 +206,7 @@ public void TestConsentRequest() Assert.False(consentObj.GetValue("feedback").ToObject()); Assert.False(consentObj.GetValue("star-rating").ToObject()); Assert.False(consentObj.GetValue("remote-config").ToObject()); + Assert.False(consentObj.GetValue("content").ToObject()); } /// @@ -241,7 +242,7 @@ public void TestGiveIndividualConsents() NameValueCollection collection = HttpUtility.ParseQueryString(request.Request); JObject consentObj = JObject.Parse(collection.Get("consent")); - Assert.Equal(10, consentObj.Count); + Assert.Equal(11, consentObj.Count); Assert.False(consentObj.GetValue("push").ToObject()); Assert.True(consentObj.GetValue("users").ToObject()); Assert.False(consentObj.GetValue("views").ToObject()); @@ -252,6 +253,7 @@ public void TestGiveIndividualConsents() Assert.False(consentObj.GetValue("feedback").ToObject()); Assert.False(consentObj.GetValue("star-rating").ToObject()); Assert.False(consentObj.GetValue("remote-config").ToObject()); + Assert.False(consentObj.GetValue("content").ToObject()); Dictionary consentToRemove = new Dictionary(); consentToRemove.Add(ConsentFeatures.Crashes, false); @@ -265,7 +267,7 @@ public void TestGiveIndividualConsents() collection = HttpUtility.ParseQueryString(request.Request); consentObj = JObject.Parse(collection.Get("consent")); - Assert.Equal(10, consentObj.Count); + Assert.Equal(11, consentObj.Count); Assert.False(consentObj.GetValue("push").ToObject()); Assert.True(consentObj.GetValue("users").ToObject()); Assert.False(consentObj.GetValue("views").ToObject()); @@ -276,6 +278,7 @@ public void TestGiveIndividualConsents() Assert.False(consentObj.GetValue("feedback").ToObject()); Assert.False(consentObj.GetValue("star-rating").ToObject()); Assert.False(consentObj.GetValue("remote-config").ToObject()); + Assert.False(consentObj.GetValue("content").ToObject()); } } } diff --git a/countlyCommon/TestingRelated/DeviceIdTests.cs b/countlyCommon/TestingRelated/DeviceIdTests.cs index ae957ae9..219e4235 100644 --- a/countlyCommon/TestingRelated/DeviceIdTests.cs +++ b/countlyCommon/TestingRelated/DeviceIdTests.cs @@ -141,7 +141,7 @@ public async void TestConsent_ChangeDeviceIdWithoutMerge() collection = HttpUtility.ParseQueryString(request.Request); JObject consentObj = JObject.Parse(collection.Get("consent")); - Assert.Equal(10, consentObj.Count); + Assert.Equal(11, consentObj.Count); Assert.False(consentObj.GetValue("push").ToObject()); Assert.True(consentObj.GetValue("users").ToObject()); Assert.False(consentObj.GetValue("views").ToObject()); @@ -152,6 +152,7 @@ public async void TestConsent_ChangeDeviceIdWithoutMerge() Assert.False(consentObj.GetValue("feedback").ToObject()); Assert.False(consentObj.GetValue("star-rating").ToObject()); Assert.False(consentObj.GetValue("remote-config").ToObject()); + Assert.False(consentObj.GetValue("content").ToObject()); type = collection.Get("t"); Assert.False(string.IsNullOrEmpty(type)); diff --git a/countlyCommon/TestingRelated/TestHelper.cs b/countlyCommon/TestingRelated/TestHelper.cs index a708bb9c..19c9cf00 100644 --- a/countlyCommon/TestingRelated/TestHelper.cs +++ b/countlyCommon/TestingRelated/TestHelper.cs @@ -528,12 +528,16 @@ internal static void ValidateRequestInQueue(string deviceId, string appKey, IDic } } - internal static void ValidateRequest(Dictionary request, IDictionary paramaters) + internal static void ValidateRequest(Dictionary request, IDictionary paramaters, IDictionary> customValidators = null) { ValidateBaseParams(request, DEVICE_ID, APP_KEY, 0); Assert.Equal(11 + paramaters.Count, request.Count); // + rr foreach (KeyValuePair item in paramaters) { - Assert.Equal(item.Value.ToString(), request[item.Key]); + if (customValidators != null && customValidators.ContainsKey(item.Key)) { + customValidators[item.Key].Invoke(request[item.Key], item.Value); + } else { + Assert.Equal(item.Value.ToString(), request[item.Key]); + } } } diff --git a/countlyCommon/TestingRelated/UserDetailsTests.cs b/countlyCommon/TestingRelated/UserDetailsTests.cs index 897153ea..b2824d4c 100644 --- a/countlyCommon/TestingRelated/UserDetailsTests.cs +++ b/countlyCommon/TestingRelated/UserDetailsTests.cs @@ -232,7 +232,13 @@ public void SetUserDetails_SessionEventsTriggers() 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(server.Requests[6].Params, TestHelper.Dict("end_session", "1", "session_duration", 3)); + // 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), + new Dictionary> { + { "session_duration", (actual, _) => Assert.True(int.Parse(actual) >= 2 && int.Parse(actual) <= 4, "session_duration was " + actual) } + }); server.Dispose(); } diff --git a/countlyCommon/countlyCommon/CountlyBase.cs b/countlyCommon/countlyCommon/CountlyBase.cs index d00505ba..83bd4fd8 100644 --- a/countlyCommon/countlyCommon/CountlyBase.cs +++ b/countlyCommon/countlyCommon/CountlyBase.cs @@ -60,6 +60,8 @@ public enum LogLevel { VERBOSE, DEBUG, INFO, WARNING, ERROR }; internal CountlyConfig Configuration; internal ModuleBackendMode moduleBackendMode; internal ModuleRemoteConfig moduleRemoteConfig; + internal ModuleFeedback moduleFeedback; + internal ModuleContent moduleContent; public abstract string sdkName(); @@ -199,7 +201,7 @@ internal enum ConsentChangedAction } public enum ConsentFeatures { - Sessions, Events, Location, Crashes, Users, Views, Push, Feedback, StarRating, RemoteConfig + Sessions, Events, Location, Crashes, Users, Views, Push, Feedback, StarRating, RemoteConfig, Content }; internal abstract Metrics GetSessionMetrics(); @@ -294,9 +296,11 @@ internal async Task Upload() { UtilityHelper.CountlyLogging("[CountlyBase] Calling 'Upload'"); bool success = false; - bool shouldContinue = false; - do { + // Iterative drain with a no-progress guard.The guard stops once a pass makes no progress, so no stuck queue can loop forever. + int previousPending = int.MaxValue; + + while (true) { if (deferUpload) { return true; } @@ -322,33 +326,38 @@ internal async Task Upload() success = await UploadUserDetails(); } - if (success && Configuration.autoSendUserDetails) { + // Drain the request queue in BOTH modes. + if (success) { success = await UploadStoredRequests(); } - if (success && !uploadInProgress) { - int sC, exC, evC, rC; - bool isChanged; + if (!success || uploadInProgress) { + UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, in progress or unsuccessful"); + break; + } - lock (sync) { - sC = Sessions.Count; - exC = Exceptions.Count; - evC = Events.Count; - rC = StoredRequests.Count; - isChanged = !Configuration.autoSendUserDetails && UserDetails.isChanged; // if the auto flushing UPs used, this should not work at all - } + int sC, exC, evC, rC; + bool isChanged; - UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, " + sC + " " + exC + " " + evC + " " + rC + " " + isChanged); + lock (sync) { + sC = Sessions.Count; + exC = Exceptions.Count; + evC = Events.Count; + rC = StoredRequests.Count; + isChanged = !Configuration.autoSendUserDetails && UserDetails.isChanged && IsConsentGiven(ConsentFeatures.Users); + } - if (sC > 0 || exC > 0 || evC > 0 || rC > 0 || isChanged) { - //work still needs to be done - return await Upload(); - } - } else { - UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, in progress"); + UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, " + sC + " " + exC + " " + evC + " " + rC + " " + isChanged); + + int pending = sC + exC + evC + rC + (isChanged ? 1 : 0); + if (pending == 0 || pending >= previousPending) { + // Everything drained, or a pass made no progress (an item can't be flushed) -> stop + // instead of looping forever; leftovers retry on the next Upload() trigger. + break; } - } while (success && shouldContinue); + previousPending = pending; + } return success; } @@ -1210,6 +1219,9 @@ internal async Task HaltInternal(bool clearStorage = true) // modules moduleBackendMode = null; moduleRemoteConfig = null; + moduleFeedback = null; + if (moduleContent != null) { moduleContent.ExitContentZone(); } // stop the poll timer + moduleContent = null; } if (clearStorage) { await ClearStorage(); @@ -1511,6 +1523,8 @@ protected async Task InitBase(CountlyConfig config) } moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl); + moduleFeedback = new ModuleFeedback(requestHelper, ServerUrl); + moduleContent = new ModuleContent(requestHelper, ServerUrl); UtilityHelper.CountlyLogging("[CountlyBase] Finished 'InitBase'"); await OnInitComplete(); @@ -1860,6 +1874,10 @@ private async Task ActionsOnConsentChanges(Dictionary upd await RemoteConfig().DownloadKeys(); } break; + case ConsentFeatures.Content: + //content consent removed -> stop the polling timer (and invalidate any in-flight fetch) + if (!isGiven && moduleContent != null) { moduleContent.ExitContentZone(); } + break; } } } @@ -1996,8 +2014,8 @@ public BackendMode BackendMode() /// public RemoteConfig RemoteConfig() { - if (Configuration.backendMode) { - UtilityHelper.CountlyLogging("[CountlyBase] RemoteConfig, backend mode is enabled, will omit this call"); + if (Configuration == null || Configuration.backendMode) { + UtilityHelper.CountlyLogging("[CountlyBase] RemoteConfig, backend mode is enabled or SDK not initialized, will omit this call"); return new MockRemoteConfig(); } @@ -2011,5 +2029,53 @@ public RemoteConfig RemoteConfig() return moduleRemoteConfig; } + + /// + /// Returns the Feedback interface for retrieving/displaying/reporting feedback widgets. + /// If backend mode is enabled, the module is unavailable, or Feedback consent is not + /// given, a no-op is returned instead. + /// + public Feedback Feedback() + { + if (Configuration == null || Configuration.backendMode) { + UtilityHelper.CountlyLogging("[CountlyBase] Feedback, backend mode is enabled or SDK not initialized, will omit this call"); + return new MockFeedback(); + } + + if (moduleFeedback == null) { + return new MockFeedback(); + } + + if (!IsConsentGiven(ConsentFeatures.Feedback)) { + return new MockFeedback(); + } + + return moduleFeedback; + } + + /// Registers the UI content-display bridge (ungated; no-op if uninitialized). + public void SetContentDisplay(IContentDisplay display) + { + if (moduleContent != null) { moduleContent.display = display; } + } + + /// + /// Returns the Content interface (experimental). A no-op is + /// returned when backend mode is enabled, the module is unavailable, or Content consent + /// is not given. + /// + public Content Content() + { + if (Configuration == null || Configuration.backendMode) { + return new MockContent(); + } + if (moduleContent == null) { + return new MockContent(); + } + if (!IsConsentGiven(ConsentFeatures.Content)) { + return new MockContent(); + } + return moduleContent; + } } } diff --git a/countlyCommon/countlyCommon/Entities/ContentModels.cs b/countlyCommon/countlyCommon/Entities/ContentModels.cs new file mode 100644 index 00000000..0cb47d49 --- /dev/null +++ b/countlyCommon/countlyCommon/Entities/ContentModels.cs @@ -0,0 +1,47 @@ +using Newtonsoft.Json.Linq; + +namespace CountlySDK.CountlyCommon +{ + public class ContentPlacement { public int X, Y, W, H; } + + public class ContentScreen { public int Width, Height; } + + public class ContentData + { + public string Url; + public ContentPlacement Portrait; + public ContentPlacement Landscape; + } + + public static class ContentParser + { + /// Parses a /o/sdk/content response; returns null if it has no usable content. + public static ContentData ParseContentResponse(string json) + { + if (string.IsNullOrEmpty(json)) { return null; } + try { + JObject root = JObject.Parse(json); + string url = (string)root["html"]; + JObject geo = root["geo"] as JObject; + if (string.IsNullOrEmpty(url) || geo == null) { return null; } + + ContentPlacement p = ToPlacement(geo["p"]); + ContentPlacement l = ToPlacement(geo["l"]); + if (p == null && l == null) { return null; } + + return new ContentData { Url = url, Portrait = p, Landscape = l }; + } catch { + return null; + } + } + + private static ContentPlacement ToPlacement(JToken t) + { + if (!(t is JObject o)) { return null; } + return new ContentPlacement { + X = (int?)o["x"] ?? 0, Y = (int?)o["y"] ?? 0, + W = (int?)o["w"] ?? 0, H = (int?)o["h"] ?? 0 + }; + } + } +} diff --git a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs index e63a9917..93f85520 100644 --- a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs +++ b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs @@ -51,6 +51,19 @@ public class CountlyConfigBase /// public int sessionUpdateInterval = 60; + private int _contentZoneTimerInterval = 30; + + /// + /// Content zone poll interval in seconds. Values <= 15 are ignored. Default 30. (Experimental.) + /// + public int ContentZoneTimerInterval { + get { return _contentZoneTimerInterval; } + set { if (value > 15) { _contentZoneTimerInterval = value; } } + } + + /// Optional callback invoked when a shown content item is closed. (Experimental.) + public System.Action GlobalContentCallback { get; set; } + // /// Maximum size of all string keys /// diff --git a/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs new file mode 100644 index 00000000..98e2a0d2 --- /dev/null +++ b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace CountlySDK.CountlyCommon +{ + public enum FeedbackWidgetType { survey, nps, rating } + + public class CountlyFeedbackWidget + { + public string widgetId; + public FeedbackWidgetType type; + public string name; + public List tags = new List(); + } + + public static class FeedbackWidgetParser + { + public static CountlyFeedbackWidget[] ParseAvailableWidgets(string responseText) + { + List result = new List(); + if (string.IsNullOrEmpty(responseText)) { return result.ToArray(); } + + try { + JObject root = JObject.Parse(responseText); + if (!(root["result"] is JArray arr)) { return result.ToArray(); } + + foreach (JToken item in arr) { + string typeStr = (string)item["type"]; + if (typeStr == null) { continue; } + if (!TryParseWidgetType(typeStr, out FeedbackWidgetType type)) { continue; } + + CountlyFeedbackWidget w = new CountlyFeedbackWidget { + widgetId = (string)item["_id"], + type = type, + name = (string)item["name"] + }; + if (item["tg"] is JArray tags) { + foreach (JToken t in tags) { w.tags.Add((string)t); } + } + if (!string.IsNullOrEmpty(w.widgetId)) { result.Add(w); } + } + } catch { /* malformed response -> empty list */ } + + return result.ToArray(); + } + + // net35-safe replacement for Enum.TryParse (which is .NET 4.0+). The enum member names + // are the exact server type strings. + private static bool TryParseWidgetType(string value, out FeedbackWidgetType type) + { + switch (value) { + case "survey": type = FeedbackWidgetType.survey; return true; + case "nps": type = FeedbackWidgetType.nps; return true; + case "rating": type = FeedbackWidgetType.rating; return true; + default: type = default(FeedbackWidgetType); return false; + } + } + } +} diff --git a/countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs b/countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs new file mode 100644 index 00000000..5dd06ec9 --- /dev/null +++ b/countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace CountlySDK.CountlyCommon.Helpers +{ + /// + /// Builds the content-specific query params for a /o/sdk/content fetch (base params are added + /// by RequestHelper.BuildRequest). Public so it can be unit-tested directly. (Experimental.) + /// + public static class ContentRequestBuilder + { + public static IDictionary BuildParams(ContentScreen screen, string[] categories, + string language, string deviceType, string contentId) + { + int w = screen != null ? screen.Width : 0; + int h = screen != null ? screen.Height : 0; + // Desktop reports the same rect for both orientations. + string resolution = "{\"l\":{\"w\":" + w + ",\"h\":" + h + "},\"p\":{\"w\":" + w + ",\"h\":" + h + "}}"; + + IDictionary p = new Dictionary { + { "method", "queue" }, + { "resolution", resolution }, + { "category", CategoryList(categories) }, + { "la", language ?? "" }, + { "dt", deviceType ?? "desktop" } + }; + if (!string.IsNullOrEmpty(contentId)) { + p.Add("content_id", contentId); + p.Add("preview", "true"); + } + return p; + } + + private static string CategoryList(string[] categories) + { + if (categories == null || categories.Length == 0) { return "[]"; } + string[] escaped = new string[categories.Length]; + for (int i = 0; i < categories.Length; i++) { + escaped[i] = categories[i] == null ? "" : System.Uri.EscapeDataString(categories[i]); + } + return "[" + string.Join(", ", escaped) + "]"; + } + } +} diff --git a/countlyCommon/countlyCommon/Helpers/RequestHelper.cs b/countlyCommon/countlyCommon/Helpers/RequestHelper.cs index 8f88ebb1..fb565016 100644 --- a/countlyCommon/countlyCommon/Helpers/RequestHelper.cs +++ b/countlyCommon/countlyCommon/Helpers/RequestHelper.cs @@ -101,6 +101,9 @@ internal string CreateConsentUpdateRequest(Dictionary upd case ConsentFeatures.RemoteConfig: consentChanges += "\"remote-config\":" + (value ? "true" : "false"); break; + case ConsentFeatures.Content: + consentChanges += "\"content\":" + (value ? "true" : "false"); + break; default: consentChanges += "\"unknown\":false"; break; diff --git a/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs b/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs new file mode 100644 index 00000000..648b8a2d --- /dev/null +++ b/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace CountlySDK.CountlyCommon +{ + public class WidgetRect { public int X, Y, W, H; } + + public class WidgetAction + { + public bool IsActionEvent; + public bool Close; + public bool HasResize; + public WidgetRect Portrait; + public WidgetRect Landscape; + } + + public static class WidgetActionParser + { + private const string ActionHost = "countly_action_event"; + + public static WidgetAction Parse(string url) + { + WidgetAction action = new WidgetAction(); + if (string.IsNullOrEmpty(url) || url.IndexOf(ActionHost, StringComparison.Ordinal) < 0) { + return action; + } + action.IsActionEvent = true; + + Dictionary q = ParseQuery(url); + if (q.TryGetValue("close", out string close) && close == "1") { + action.Close = true; + } + if (q.TryGetValue("resize_me", out string resize) && !string.IsNullOrEmpty(resize)) { + try { + JObject obj = JObject.Parse(resize); + action.Portrait = ToRect(obj["p"]); + action.Landscape = ToRect(obj["l"]); + action.HasResize = action.Portrait != null || action.Landscape != null; + } catch { /* ignore malformed resize payload */ } + } + return action; + } + + private static WidgetRect ToRect(JToken t) + { + if (t == null) { return null; } + return new WidgetRect { + X = (int?)t["x"] ?? 0, Y = (int?)t["y"] ?? 0, + W = (int?)t["w"] ?? 0, H = (int?)t["h"] ?? 0 + }; + } + + private static Dictionary ParseQuery(string url) + { + Dictionary result = new Dictionary(); + int qi = url.IndexOf('?'); + if (qi < 0 || qi == url.Length - 1) { return result; } + foreach (string pair in url.Substring(qi + 1).Split('&')) { + int eq = pair.IndexOf('='); + if (eq <= 0) { continue; } + string key = pair.Substring(0, eq); + string val = Uri.UnescapeDataString(pair.Substring(eq + 1)); + result[key] = val; + } + return result; + } + } +} diff --git a/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs b/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs new file mode 100644 index 00000000..6c941cbb --- /dev/null +++ b/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace CountlySDK.CountlyCommon.Helpers +{ + /// + /// Builds the display URL for a feedback widget. Pure/UI-free so it can be unit-tested and + /// reused by the WebView2 UI package. The custom={"tc":1,"rw":1,"xb":1} object opts + /// into versioned/resizable widgets (so the widget emits resize/close action events). + /// + public static class WidgetUrlBuilder + { + public static string BuildFeedbackWidgetUrl(string serverUrl, CountlyFeedbackWidget widget, IDictionary baseParams) + { + StringBuilder sb = new StringBuilder(); + sb.Append(serverUrl.TrimEnd('/')); + sb.Append("/feedback/").Append(widget.type).Append("?"); + sb.Append("widget_id=").Append(Uri.EscapeDataString(widget.widgetId)); + Append(sb, "device_id", baseParams, "device_id"); + Append(sb, "app_key", baseParams, "app_key"); + Append(sb, "sdk_name", baseParams, "sdk_name"); + Append(sb, "sdk_version", baseParams, "sdk_version"); + Append(sb, "app_version", baseParams, "av"); + sb.Append("&platform=windows"); + sb.Append("&custom=").Append(Uri.EscapeDataString("{\"tc\":1,\"rw\":1,\"xb\":1}")); + return sb.ToString(); + } + + private static void Append(StringBuilder sb, string outKey, IDictionary src, string srcKey) + { + if (src != null && src.TryGetValue(srcKey, out object v) && v != null) { + sb.Append("&").Append(outKey).Append("=").Append(Uri.EscapeDataString(Convert.ToString(v))); + } + } + } +} diff --git a/countlyCommon/countlyCommon/Modules/ModuleContent.cs b/countlyCommon/countlyCommon/Modules/ModuleContent.cs new file mode 100644 index 00000000..7ee93f4a --- /dev/null +++ b/countlyCommon/countlyCommon/Modules/ModuleContent.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CountlySDK.CountlyCommon.Helpers; +using CountlySDK.CountlyCommon.Server.Responses; +using CountlySDK.Helpers; +using static CountlySDK.CountlyCommon.CountlyBase; + +namespace CountlySDK.CountlyCommon +{ + internal class ModuleContent : Content, IDisposable + { + private readonly RequestHelper requestHelper; + private readonly string ServerUrl; + internal IContentDisplay display; // set via Countly.Instance.SetContentDisplay(...) + + // Fully-qualified System.Threading.Timer to avoid the netstd CountlySDK.Helpers.TimerCallback collision. + private System.Threading.Timer _timer; + private readonly object _lock = new object(); + private bool _shouldFetch; + private bool _inZone; + private int _waitForDelay; + private volatile bool _fetching; + private int _generation; // bumped on ExitContentZone so an in-flight fetch can detect it was cancelled + private const int StartDelayMs = 4000; + + public ModuleContent(RequestHelper requestHelper, string serverUrl) + { + this.requestHelper = requestHelper; + ServerUrl = serverUrl; + } + + public void EnterContentZone(string[] categories = null) + { + if (display == null) { + UtilityHelper.CountlyLogging("[ModuleContent] EnterContentZone, no display registered; ignoring", LogLevel.WARNING); + return; + } + lock (_lock) { + if (_inZone) { return; } + _shouldFetch = true; + _waitForDelay = 0; + int periodMs = Math.Max(1000, Countly.Instance.Configuration.ContentZoneTimerInterval * 1000); + if (_timer == null) { + _timer = new System.Threading.Timer(OnTick, categories, StartDelayMs, periodMs); + } else { + _timer.Change(StartDelayMs, periodMs); + } + } + } + + public void ExitContentZone() + { + lock (_lock) { + _shouldFetch = false; + _inZone = false; + _waitForDelay = 0; + _generation++; // invalidate any fetch already in flight so it won't present after we exit + if (_timer != null) { _timer.Dispose(); _timer = null; } + } + } + + public void Dispose() + { + // Standard disposal entry point (satisfies CA1001 for the owned System.Threading.Timer); + // ExitContentZone stops polling and disposes the timer. + ExitContentZone(); + } + + public void RefreshContentZone() + { + ExitContentZone(); + EnterContentZone(null); + } + + public void PreviewContent(string contentId) + { + if (display == null || string.IsNullOrEmpty(contentId)) { return; } + lock (_lock) { if (_inZone) { return; } } + FetchAndPresent(new string[0], contentId); // fire-and-forget; errors handled inside + } + + private void OnTick(object state) + { + // Stop if Content consent was revoked while the zone was active (defense in depth; the + // consent-change handler also calls ExitContentZone, which disposes this timer). + if (!Countly.Instance.IsConsentGiven(ConsentFeatures.Content)) { ExitContentZone(); return; } + lock (_lock) { + if (_waitForDelay > 0) { _waitForDelay--; return; } + if (!_shouldFetch || _fetching) { return; } + _fetching = true; + } + FetchAndPresent((string[])state, null); // fire-and-forget + } + + private async Task FetchAndPresent(string[] categories, string contentId) + { + int gen; + lock (_lock) { gen = _generation; } + try { + ContentScreen screen = display.GetScreen(); + string language = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; + IDictionary parameters = ContentRequestBuilder.BuildParams(screen, categories, language, "desktop", contentId); + + RequestResult rr = await Api.Instance.SendDirectRequest( + ServerUrl, await requestHelper.BuildRequest(parameters), "/o/sdk/content"); + + ContentData content = (rr != null && rr.responseCode == 200) ? ContentParser.ParseContentResponse(rr.responseText) : null; + if (content == null) { return; } + + lock (_lock) { + // Zone was exited/refreshed/halted (or consent revoked) while this fetch was in + // flight -> discard the result instead of presenting stale content. + if (gen != _generation) { return; } + _shouldFetch = false; _inZone = true; + } + display.Present(content.Portrait, content.Landscape, content.Url, OnContentClosed); + } catch (Exception ex) { + UtilityHelper.CountlyLogging("[ModuleContent] FetchAndPresent failed: " + ex.Message, LogLevel.ERROR); + } finally { + _fetching = false; + } + } + + private void OnContentClosed() + { + lock (_lock) { _waitForDelay = 2; _shouldFetch = true; _inZone = false; } + Action cb = Countly.Instance.Configuration != null ? Countly.Instance.Configuration.GlobalContentCallback : null; + if (cb != null) { cb(); } + } + } + + internal class MockContent : Content + { + public void EnterContentZone(string[] categories = null) { } + public void ExitContentZone() { } + public void RefreshContentZone() { } + public void PreviewContent(string contentId) { } + } + + /// Retrieves and displays Countly content (experimental). + public interface Content + { + void EnterContentZone(string[] categories = null); + void ExitContentZone(); + void RefreshContentZone(); + void PreviewContent(string contentId); + } + + /// + /// Bridge the UI package implements so the UI-less core can size the fetch to the screen and + /// display server-placed content. Registered via Countly.Instance.SetContentDisplay(...). + /// + public interface IContentDisplay + { + ContentScreen GetScreen(); + void Present(ContentPlacement portrait, ContentPlacement landscape, string url, Action onClosed); + } +} diff --git a/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs new file mode 100644 index 00000000..534cea2f --- /dev/null +++ b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +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 class ModuleFeedback : Feedback + { + private readonly RequestHelper requestHelper; + private readonly string ServerUrl; + + // Server-contract event keys. CountlyBase declares these as `private const`, so they + // are re-declared here for the module (the exact string values must match, because + // CheckConsentOnKey routes them to Feedback/StarRating consent). + private const string NPS_EVENT_KEY = "[CLY]_nps"; + private const string SURVEY_EVENT_KEY = "[CLY]_survey"; + private const string STAR_RATING_EVENT_KEY = "[CLY]_star_rating"; + + public ModuleFeedback(RequestHelper requestHelper, string serverUrl) + { + this.requestHelper = requestHelper; + ServerUrl = serverUrl; + } + + public async Task GetAvailableFeedbackWidgets() + { + UtilityHelper.CountlyLogging("[ModuleFeedback] GetAvailableFeedbackWidgets called"); + + IDictionary parameters = new Dictionary { + { "method", "feedback" } + }; + + RequestResult requestResult = await Api.Instance.SendDirectRequest(ServerUrl, await requestHelper.BuildRequest(parameters)); + + if (requestResult != null && requestResult.responseCode == 200 && requestResult.responseText != null) { + return FeedbackWidgetParser.ParseAvailableWidgets(requestResult.responseText); + } + + UtilityHelper.CountlyLogging("[ModuleFeedback] GetAvailableFeedbackWidgets, request failed", LogLevel.ERROR); + return new CountlyFeedbackWidget[0]; + } + + public async Task GetFeedbackWidgetData(CountlyFeedbackWidget widget) + { + if (widget == null) { + UtilityHelper.CountlyLogging("[ModuleFeedback] GetFeedbackWidgetData, widget is null", LogLevel.ERROR); + return null; + } + UtilityHelper.CountlyLogging("[ModuleFeedback] GetFeedbackWidgetData for widget [" + widget.widgetId + "]"); + + IDictionary parameters = new Dictionary { + { "widget_id", widget.widgetId }, + { "shown", "1" } + }; + string endpoint = "/o/surveys/" + widget.type + "/widget"; + + RequestResult requestResult = await Api.Instance.SendDirectRequest(ServerUrl, await requestHelper.BuildRequest(parameters), endpoint); + + if (requestResult != null && requestResult.responseCode == 200 && requestResult.responseText != null) { + try { + return JObject.Parse(requestResult.responseText); + } catch { + UtilityHelper.CountlyLogging("[ModuleFeedback] GetFeedbackWidgetData, failed to parse response", LogLevel.ERROR); + } + } + return null; + } + + public async Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult) + { + if (widget == null) { + UtilityHelper.CountlyLogging("[ModuleFeedback] ReportFeedbackWidgetManually, widget is null, ignoring", LogLevel.ERROR); + return; + } + + string eventKey; + switch (widget.type) { + case FeedbackWidgetType.nps: eventKey = NPS_EVENT_KEY; break; + case FeedbackWidgetType.survey: eventKey = SURVEY_EVENT_KEY; break; + default: eventKey = STAR_RATING_EVENT_KEY; break; + } + + // Stable Add() order: platform, app_version, widget_id, then result (or closed). + Segmentation segmentation = new Segmentation(); + segmentation.Add("platform", "windows"); + segmentation.Add("app_version", new IRequestHelperImpl(Countly.Instance).GetAppVersion()); + segmentation.Add("widget_id", widget.widgetId); + + if (widgetResult == null) { + segmentation.Add("closed", "1"); + } else { + foreach (KeyValuePair kv in widgetResult) { + segmentation.Add(kv.Key, Convert.ToString(kv.Value)); + } + } + + // RecordEventInternal is protected; a module must use the public static entrypoint. + await Countly.RecordEvent(eventKey, 1, segmentation); + } + + public async Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget) + { + if (widget == null) { return null; } + IDictionary baseParams = await requestHelper.GetBaseParams(); + return WidgetUrlBuilder.BuildFeedbackWidgetUrl(ServerUrl, widget, baseParams); + } + } + + internal class MockFeedback : Feedback + { + // async-empty (no Task.FromResult, which is unavailable on net35) — mirrors MockRemoteConfig. + public async Task GetAvailableFeedbackWidgets() { return new CountlyFeedbackWidget[0]; } + public async Task GetFeedbackWidgetData(CountlyFeedbackWidget widget) { return null; } + public async Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult) { } + public async Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget) { return null; } + } + + /// + /// Defines the contract for retrieving, displaying, and reporting Countly feedback widgets + /// (Surveys, NPS, Ratings). + /// + public interface Feedback + { + /// Fetches the list of feedback widgets available for the current device. + Task GetAvailableFeedbackWidgets(); + + /// Fetches the raw widget definition JSON (for building a custom UI). + Task GetFeedbackWidgetData(CountlyFeedbackWidget widget); + + /// + /// Reports a widget result manually. Pass as null to + /// mark the widget closed/cancelled without an answer. + /// + Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult); + + /// Builds the display URL for a widget (used by the WebView2 UI package). + Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget); + } +} diff --git a/net35/Countly/Countly.csproj b/net35/Countly/Countly.csproj index cee3cd53..b6ccd80a 100644 --- a/net35/Countly/Countly.csproj +++ b/net35/Countly/Countly.csproj @@ -78,6 +78,12 @@ Entities\CountlyEvent.cs + + Entities\CountlyFeedbackWidget.cs + + + Entities\ContentModels.cs + Entities\CountlyUserDetails.cs @@ -123,6 +129,15 @@ Helpers\RequestHelper.cs + + Helpers\WidgetActionParser.cs + + + Helpers\WidgetUrlBuilder.cs + + + Helpers\ContentRequestBuilder.cs + Helpers\StorageBase.cs diff --git a/net45/Countly/Countly.csproj b/net45/Countly/Countly.csproj index 85a9eaf7..136a9f17 100644 --- a/net45/Countly/Countly.csproj +++ b/net45/Countly/Countly.csproj @@ -78,6 +78,12 @@ Entities\CountlyEvent.cs + + Entities\CountlyFeedbackWidget.cs + + + Entities\ContentModels.cs + Entities\CountlyUserDetails.cs @@ -123,6 +129,15 @@ Helpers\RequestHelper.cs + + Helpers\WidgetActionParser.cs + + + Helpers\WidgetUrlBuilder.cs + + + Helpers\ContentRequestBuilder.cs + Helpers\StorageBase.cs diff --git a/netstd/Countly/Countly.cs b/netstd/Countly/Countly.cs index d2508195..4fd516ce 100644 --- a/netstd/Countly/Countly.cs +++ b/netstd/Countly/Countly.cs @@ -29,7 +29,9 @@ THE SOFTWARE. using CountlySDK.Entities; using CountlySDK.Entities.EntityBase; using CountlySDK.Helpers; -//[assembly: InternalsVisibleTo("CountlyTest_461")] +#if COUNTLY_TESTABLE +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("CountlyTest_461")] +#endif //[assembly: InternalsVisibleTo("CountlySampleUWP")] namespace CountlySDK diff --git a/netstd/Countly/Countly.csproj b/netstd/Countly/Countly.csproj index 33c35d1d..517fff35 100644 --- a/netstd/Countly/Countly.csproj +++ b/netstd/Countly/Countly.csproj @@ -23,12 +23,17 @@ embedded + + $(DefineConstants);COUNTLY_TESTABLE + + + @@ -46,6 +51,9 @@ + + + diff --git a/netstd/CountlyTest_461/CountlyTest_461.csproj b/netstd/CountlyTest_461/CountlyTest_461.csproj index ed577f3b..f8cd36c4 100644 --- a/netstd/CountlyTest_461/CountlyTest_461.csproj +++ b/netstd/CountlyTest_461/CountlyTest_461.csproj @@ -11,7 +11,7 @@ Properties CountlyTest_461 CountlyTest_461 - v4.6.1 + v4.6.2 512 true diff --git a/ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs b/ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..679b372e --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs @@ -0,0 +1,2 @@ +// Init-based tests drive the shared static Countly singleton, so serialize the whole assembly. +[assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)] diff --git a/ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs b/ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs new file mode 100644 index 00000000..b9f50ef6 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs @@ -0,0 +1,19 @@ +using CountlySDK.Entities; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class ContentConfigTests + { + [Fact] + public void ZoneTimerInterval_DefaultsTo30_AndRejectsTooSmall() + { + CountlyConfig cc = new CountlyConfig(); + Assert.Equal(30, cc.ContentZoneTimerInterval); + cc.ContentZoneTimerInterval = 10; // <=15 ignored + Assert.Equal(30, cc.ContentZoneTimerInterval); + cc.ContentZoneTimerInterval = 45; + Assert.Equal(45, cc.ContentZoneTimerInterval); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs b/ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs new file mode 100644 index 00000000..83ea923b --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs @@ -0,0 +1,27 @@ +using System; +using CountlySDK.CountlyCommon; +using CountlySDK.UI; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + // The live overlay needs a UI thread + WebView2 runtime, so it's exercised via the demo. + // Here we assert the entry points exist and the bridge type implements IContentDisplay. + public class ContentEntryPointTests + { + [Fact] + public void EnableDisableContentZone_SignaturesExist() + { + Assert.NotNull(typeof(CountlyWebView).GetMethod("EnableContentZone", new[] { typeof(string[]) })); + Assert.NotNull(typeof(CountlyWebView).GetMethod("DisableContentZone", Type.EmptyTypes)); + } + + [Fact] + public void WebView2ContentDisplay_ImplementsBridge() + { + Type t = typeof(CountlyWebView).Assembly.GetType("CountlySDK.UI.WebView2ContentDisplay"); + Assert.NotNull(t); + Assert.True(typeof(IContentDisplay).IsAssignableFrom(t)); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ContentParserTests.cs b/ui/Countly.UI.WebView2.Tests/ContentParserTests.cs new file mode 100644 index 00000000..1bcaf052 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ContentParserTests.cs @@ -0,0 +1,31 @@ +using CountlySDK.CountlyCommon; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class ContentParserTests + { + [Fact] + public void ParsesGeoAndHtmlUrl() + { + string json = "{\"geo\":{\"p\":{\"x\":1,\"y\":2,\"w\":300,\"h\":400},\"l\":{\"x\":5,\"y\":6,\"w\":700,\"h\":500}},\"html\":\"https://s/content/abc\"}"; + ContentData c = ContentParser.ParseContentResponse(json); + Assert.NotNull(c); + Assert.Equal("https://s/content/abc", c.Url); + Assert.Equal(300, c.Portrait.W); + Assert.Equal(400, c.Portrait.H); + Assert.Equal(700, c.Landscape.W); + Assert.Equal(6, c.Landscape.Y); + } + + [Fact] + public void InvalidOrEmpty_ReturnsNull() + { + Assert.Null(ContentParser.ParseContentResponse("")); + Assert.Null(ContentParser.ParseContentResponse("{}")); + Assert.Null(ContentParser.ParseContentResponse("{\"geo\":{},\"html\":\"u\"}")); + Assert.Null(ContentParser.ParseContentResponse("{\"jsonArray\":[]}")); + Assert.Null(ContentParser.ParseContentResponse("not json")); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs b/ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs new file mode 100644 index 00000000..166895b3 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using CountlySDK.CountlyCommon; +using CountlySDK.CountlyCommon.Helpers; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class ContentRequestBuilderTests + { + [Fact] + public void BuildParams_IncludesResolutionMethodAndLocale() + { + var screen = new ContentScreen { Width = 1920, Height = 1080 }; + IDictionary p = ContentRequestBuilder.BuildParams(screen, new[] { "cat1" }, "en", "desktop", null); + Assert.Equal("queue", p["method"]); + Assert.Equal("en", p["la"]); + Assert.Equal("desktop", p["dt"]); + Assert.Contains("1920", (string)p["resolution"]); + Assert.Contains("\"w\"", (string)p["resolution"]); + Assert.False(p.ContainsKey("content_id")); + } + + [Fact] + public void BuildParams_Preview_AddsContentIdAndPreview() + { + var screen = new ContentScreen { Width = 800, Height = 600 }; + IDictionary p = ContentRequestBuilder.BuildParams(screen, null, "en", "desktop", "cid1"); + Assert.Equal("cid1", p["content_id"]); + Assert.Equal("true", p["preview"]); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj b/ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj new file mode 100644 index 00000000..bb90ea72 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0-windows + true + true + disable + false + false + false + + + + + + + + + + + + diff --git a/ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs new file mode 100644 index 00000000..4ab4f1d2 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using CountlySDK.CountlyCommon; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class FeedbackWidgetParserTests + { + [Fact] + public void ParseAvailableWidgets_ParsesAllTypes() + { + string json = "{\"result\":[" + + "{\"_id\":\"id_s\",\"type\":\"survey\",\"name\":\"S\",\"tg\":[\"/a\"]}," + + "{\"_id\":\"id_n\",\"type\":\"nps\",\"name\":\"N\",\"tg\":[]}," + + "{\"_id\":\"id_r\",\"type\":\"rating\",\"name\":\"R\",\"tg\":[\"/b\",\"/c\"]}]}"; + + CountlyFeedbackWidget[] widgets = FeedbackWidgetParser.ParseAvailableWidgets(json); + + Assert.Equal(3, widgets.Length); + Assert.Equal("id_s", widgets[0].widgetId); + Assert.Equal(FeedbackWidgetType.survey, widgets[0].type); + Assert.Equal("N", widgets[1].name); + Assert.Equal(FeedbackWidgetType.nps, widgets[1].type); + Assert.Equal(new List { "/b", "/c" }, widgets[2].tags); + } + + [Fact] + public void ParseAvailableWidgets_InvalidOrEmpty_ReturnsEmpty() + { + Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("")); + Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("{}")); + Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("{\"result\":\"nope\"}")); + Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("not json")); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs new file mode 100644 index 00000000..009d0f19 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CountlySDK.CountlyCommon; +using CountlySDK.UI; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + internal sealed class FakeHost : IWidgetWebHost + { + public event Action NavigationStarting; + public string NavigatedUrl; + public (int w, int h)? Resized; + public bool Closed; + + public void Navigate(string url) { NavigatedUrl = url; } + public void ResizeTo(int w, int h) { Resized = (w, h); } + public void CloseHost() { Closed = true; } + public void Fire(string url) { NavigationStarting?.Invoke(url); } + } + + internal sealed class FakeFeedback : Feedback + { + public bool ReportedClose; + public Task GetAvailableFeedbackWidgets() { return Task.FromResult(new CountlyFeedbackWidget[0]); } + public Task GetFeedbackWidgetData(CountlyFeedbackWidget w) { return Task.FromResult(null); } + public Task ReportFeedbackWidgetManually(CountlyFeedbackWidget w, JObject d, Dictionary r) + { + if (r == null) { ReportedClose = true; } + return Task.FromResult(0); + } + public Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget w) { return Task.FromResult("https://s/feedback/nps?widget_id=" + w.widgetId); } + } + + public class FeedbackWidgetPresenterTests + { + [Fact] + public async Task Start_NavigatesToConstructedUrl() + { + FakeHost host = new FakeHost(); + FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), isLandscape: false, onClosed: () => { }); + await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }); + Assert.Equal("https://s/feedback/nps?widget_id=w1", host.NavigatedUrl); + } + + [Fact] + public async Task Resize_PicksPortraitRect_WhenPortrait() + { + FakeHost host = new FakeHost(); + FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), isLandscape: false, onClosed: () => { }); + await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }); + + host.Fire("https://countly_action_event?cly_widget_command=1&action=resize_me&resize_me=" + + Uri.EscapeDataString("{\"p\":{\"x\":0,\"y\":0,\"w\":320,\"h\":480},\"l\":{\"x\":0,\"y\":0,\"w\":800,\"h\":600}}")); + + Assert.Equal((320, 480), host.Resized); + } + + [Fact] + public async Task Close_ReportsClosedAndClosesHost() + { + FakeHost host = new FakeHost(); + FakeFeedback fb = new FakeFeedback(); + bool closedCb = false; + FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, fb, isLandscape: false, onClosed: () => closedCb = true); + await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }); + + host.Fire("https://countly_action_event?cly_widget_command=1&close=1"); + + Assert.True(fb.ReportedClose); + Assert.True(host.Closed); + Assert.True(closedCb); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs b/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs new file mode 100644 index 00000000..7a5d2769 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; + +namespace Countly.UI.WebView2.Tests +{ + // Minimal self-contained HTTP mock (no dependency on the SDK's TestProject_common helpers, + // which reach SDK internals not visible from this external assembly). Records requests and + // returns JSON from an optional responder (rawUrl, body) => json. + internal sealed class LocalMockServer : IDisposable + { + private readonly HttpListener _listener; + private readonly Func _responder; + + public string Url { get; } + public List Requests { get; } = new List(); + + public LocalMockServer(Func responder = null) + { + _responder = responder; + int port = FreePort(); + Url = $"http://localhost:{port}/"; + _listener = new HttpListener(); + _listener.Prefixes.Add(Url); + _listener.Start(); + Thread t = new Thread(Loop) { IsBackground = true }; + t.Start(); + } + + private void Loop() + { + while (_listener.IsListening) { + try { + HttpListenerContext ctx = _listener.GetContext(); + string body; + using (StreamReader r = new StreamReader(ctx.Request.InputStream)) { body = r.ReadToEnd(); } + lock (Requests) { Requests.Add(new Captured { RawUrl = ctx.Request.RawUrl, Method = ctx.Request.HttpMethod, Body = body }); } + + string json = _responder?.Invoke(ctx.Request.RawUrl, body) ?? "{\"result\":\"success\"}"; + byte[] buf = Encoding.UTF8.GetBytes(json); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = buf.Length; + ctx.Response.OutputStream.Write(buf, 0, buf.Length); + ctx.Response.Close(); + } catch { /* listener stopped */ } + } + } + + private static int FreePort() + { + TcpListener l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + int p = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return p; + } + + public void Dispose() { try { _listener.Stop(); } catch { /* listener already stopped/disposed; nothing to do */ } } + + public sealed class Captured + { + public string RawUrl; + public string Method; + public string Body; + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs new file mode 100644 index 00000000..f65eda06 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs @@ -0,0 +1,50 @@ +using CountlySDK.Entities; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + // Runtime verification of the Content() accessor wiring. 'Countly' is fully qualified because + // the test's 'Countly.UI.WebView2.Tests' namespace shadows the CountlySDK.Countly class. + public class ModuleContentAccessorTests + { + [Fact] + public void Content_ReturnsMock_WhenConsentRequiredAndNotGiven() + { + CountlySDK.Countly.Halt(); + CountlyConfig cc = new CountlyConfig { + serverUrl = "https://no-server.count.ly", + appKey = "APP_KEY", + appVersion = "1.0", + consentRequired = true + }; + CountlySDK.Countly.Instance.Init(cc).Wait(); + + // No Content consent -> MockContent -> operations are safe no-ops. + CountlySDK.Countly.Instance.Content().EnterContentZone(); + CountlySDK.Countly.Instance.Content().ExitContentZone(); + CountlySDK.Countly.Instance.Content().RefreshContentZone(); + CountlySDK.Countly.Instance.Content().PreviewContent("cid"); + + CountlySDK.Countly.Halt(); + } + + [Fact] + public void Accessors_WhenNotInitialized_ReturnMocksWithoutThrowing() + { + // Repro of the reported crash: calling an accessor before Init (Configuration == null) + // NRE'd at CountlyBase.Content() 'if (Configuration.backendMode)'. Configuration is + // internal and never nulled by Halt, so force the not-initialized state via reflection. + CountlySDK.Countly.Halt(); + System.Reflection.FieldInfo cfg = typeof(CountlySDK.CountlyCommon.CountlyBase).GetField( + "Configuration", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(cfg); + cfg.SetValue(CountlySDK.Countly.Instance, null); + + // Each accessor must return a no-op mock, not throw NullReferenceException. + Assert.NotNull(CountlySDK.Countly.Instance.Content()); + CountlySDK.Countly.Instance.Content().EnterContentZone(); + Assert.NotNull(CountlySDK.Countly.Instance.Feedback()); + Assert.NotNull(CountlySDK.Countly.Instance.RemoteConfig()); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs new file mode 100644 index 00000000..21f20cca --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading; +using CountlySDK.CountlyCommon; +using CountlySDK.Entities; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + internal sealed class FakeContentDisplay : IContentDisplay + { + public ContentScreen Screen = new ContentScreen { Width = 1000, Height = 800 }; + public volatile string PresentedUrl; + public volatile ContentPlacement PresentedPortrait; + public Action LastOnClosed; + + public ContentScreen GetScreen() { return Screen; } + public void Present(ContentPlacement portrait, ContentPlacement landscape, string url, Action onClosed) + { + PresentedPortrait = portrait; + PresentedUrl = url; + LastOnClosed = onClosed; + } + } + + public class ModuleContentZoneTests + { + [Fact] + public void EnterContentZone_FetchesAndPresents() + { + using LocalMockServer server = new LocalMockServer((rawUrl, body) => + (rawUrl + body).Contains("method=queue") + ? "{\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":320,\"h\":480},\"l\":{\"x\":0,\"y\":0,\"w\":800,\"h\":600}},\"html\":\"https://s/content/1\"}" + : null); + + CountlySDK.Countly.Halt(); + CountlyConfig cc = new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }; + cc.ContentZoneTimerInterval = 16; + CountlySDK.Countly.Instance.Init(cc).Wait(); + + FakeContentDisplay display = new FakeContentDisplay(); + CountlySDK.Countly.Instance.SetContentDisplay(display); + CountlySDK.Countly.Instance.Content().EnterContentZone(); + + for (int i = 0; i < 120 && display.PresentedUrl == null; i++) { Thread.Sleep(100); } + + Assert.Equal("https://s/content/1", display.PresentedUrl); + Assert.Equal(320, display.PresentedPortrait.W); + + CountlySDK.Countly.Instance.Content().ExitContentZone(); + CountlySDK.Countly.Halt(); + } + + [Fact] + public void PreviewContent_OneShotFetchesAndPresents() + { + using LocalMockServer server = new LocalMockServer((rawUrl, body) => + (rawUrl + body).Contains("preview=true") + ? "{\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":300,\"h\":300},\"l\":{\"x\":0,\"y\":0,\"w\":300,\"h\":300}},\"html\":\"https://s/content/preview\"}" + : null); + + CountlySDK.Countly.Halt(); + CountlyConfig cc = new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }; + CountlySDK.Countly.Instance.Init(cc).Wait(); + + FakeContentDisplay display = new FakeContentDisplay(); + CountlySDK.Countly.Instance.SetContentDisplay(display); + CountlySDK.Countly.Instance.Content().PreviewContent("cid1"); + + for (int i = 0; i < 50 && display.PresentedUrl == null; i++) { Thread.Sleep(100); } + Assert.Equal("https://s/content/preview", display.PresentedUrl); + CountlySDK.Countly.Halt(); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs new file mode 100644 index 00000000..83218f18 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs @@ -0,0 +1,34 @@ +using CountlySDK.CountlyCommon; +using CountlySDK.Entities; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + // Runtime verification of the Feedback() accessor wiring (T2). Uses only PUBLIC API, so it + // works from this external harness (no SDK internals needed). Confirms Countly.Init runs + // under the net8.0-windows host and that consent gating returns the no-op MockFeedback. + // Note: 'Countly' must be fully qualified as CountlySDK.Countly here, because the test's own + // 'Countly.UI.WebView2.Tests' namespace shadows the CountlySDK.Countly class name. + public class ModuleFeedbackAccessorTests + { + [Fact] + public void Feedback_ReturnsMock_WhenConsentRequiredAndNotGiven() + { + CountlySDK.Countly.Halt(); + CountlyConfig cc = new CountlyConfig { + serverUrl = "https://no-server.count.ly", + appKey = "APP_KEY", + appVersion = "1.0", + consentRequired = true + }; + CountlySDK.Countly.Instance.Init(cc).Wait(); + + // Consent for Feedback not granted -> accessor returns MockFeedback -> empty, never null, no throw. + CountlyFeedbackWidget[] widgets = CountlySDK.Countly.Instance.Feedback().GetAvailableFeedbackWidgets().Result; + + Assert.NotNull(widgets); + Assert.Empty(widgets); + CountlySDK.Countly.Halt(); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs new file mode 100644 index 00000000..120e9cf2 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using CountlySDK.CountlyCommon; +using CountlySDK.Entities; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + // Local integration tests for the feedback network layer (T3/T4/T5). Uses only PUBLIC SDK + // API + a local mock server, so they run from this external harness. (The event-queue + // assertion for T5 that inspects Countly.Instance.Events directly lives in the source-linked + // TestProject_common test for the VS build; here we verify T5 via the observable /i upload.) + public class ModuleFeedbackNetworkTests + { + [Fact] + public void GetAvailableFeedbackWidgets_FetchesAndParses() + { + using LocalMockServer server = new LocalMockServer((rawUrl, body) => + body != null && body.Contains("method=feedback") + ? "{\"result\":[{\"_id\":\"w1\",\"type\":\"nps\",\"name\":\"N\",\"tg\":[]}]}" + : null); + + CountlySDK.Countly.Halt(); + CountlySDK.Countly.Instance.Init(new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }).Wait(); + + CountlyFeedbackWidget[] widgets = CountlySDK.Countly.Instance.Feedback().GetAvailableFeedbackWidgets().Result; + + Assert.Single(widgets); + Assert.Equal("w1", widgets[0].widgetId); + Assert.Equal(FeedbackWidgetType.nps, widgets[0].type); + CountlySDK.Countly.Halt(); + } + + [Fact] + public void GetFeedbackWidgetData_HitsSurveyEndpointAndParses() + { + using LocalMockServer server = new LocalMockServer((rawUrl, body) => + rawUrl.Contains("/o/surveys/") ? "{\"name\":\"My NPS\"}" : null); + + CountlySDK.Countly.Halt(); + CountlySDK.Countly.Instance.Init(new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }).Wait(); + + CountlyFeedbackWidget widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }; + JObject data = CountlySDK.Countly.Instance.Feedback().GetFeedbackWidgetData(widget).Result; + + Assert.NotNull(data); + Assert.Equal("My NPS", (string)data["name"]); + + LocalMockServer.Captured req = server.Requests.Last(r => r.RawUrl.Contains("/o/surveys/")); + Assert.Contains("/o/surveys/nps/widget", req.RawUrl); + Assert.Contains("widget_id=w1", req.RawUrl + req.Body); + Assert.Contains("shown=1", req.RawUrl + req.Body); + CountlySDK.Countly.Halt(); + } + + [Fact] + public void ReportFeedbackWidgetManually_UploadsNpsEventWithWidgetSegmentation() + { + using LocalMockServer server = new LocalMockServer((rawUrl, body) => "{\"result\":\"success\"}"); + + CountlySDK.Countly.Halt(); + CountlySDK.Countly.Instance.Init(new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }).Wait(); + + CountlyFeedbackWidget widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }; + Dictionary result = new Dictionary { { "rating", 9 } }; + CountlySDK.Countly.Instance.Feedback().ReportFeedbackWidgetManually(widget, null, result).Wait(); + + // The [CLY]_nps event is uploaded to /i; its (url-encoded) body must carry the widget segmentation. + bool found = server.Requests.Any(r => { + string decoded = System.Uri.UnescapeDataString(r.Body ?? ""); + return decoded.Contains("[CLY]_nps") && decoded.Contains("widget_id") && decoded.Contains("w1"); + }); + Assert.True(found, "No uploaded request carried the NPS feedback event. Captured bodies: " + + string.Join(" || ", server.Requests.Select(r => r.Body))); + CountlySDK.Countly.Halt(); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs b/ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs new file mode 100644 index 00000000..e305835b --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs @@ -0,0 +1,39 @@ +using System; +using CountlySDK.CountlyCommon; +using CountlySDK.UI; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + // T10-T12 verification that does NOT require a UI thread or the WebView2 runtime: + // the runtime probe is tolerant, and the entry points are asserted to exist by reflection + // (the real WebView2 display path is exercised via the sample apps). + public class WebView2AndEntryPointTests + { + [Fact] + public void WebView2Runtime_IsAvailable_DoesNotThrow() + { + bool available = WebView2Runtime.IsAvailable(out string version); + // On CI without the runtime this is false + null; on a dev machine it may be true. + Assert.True(available || version == null); + } + + [Fact] + public void PresentFeedbackWidget_Wpf_SignatureExists() + { + System.Reflection.MethodInfo m = typeof(CountlyWebView).GetMethod( + "PresentFeedbackWidget", + new[] { typeof(System.Windows.Window), typeof(CountlyFeedbackWidget), typeof(Action) }); + Assert.NotNull(m); + } + + [Fact] + public void PresentFeedbackWidget_WinForms_SignatureExists() + { + System.Reflection.MethodInfo m = typeof(CountlyWebView).GetMethod( + "PresentFeedbackWidget", + new[] { typeof(System.Windows.Forms.IWin32Window), typeof(CountlyFeedbackWidget), typeof(Action) }); + Assert.NotNull(m); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs new file mode 100644 index 00000000..9d5bce6b --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs @@ -0,0 +1,30 @@ +using System; +using CountlySDK.CountlyCommon; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class WidgetActionParserTests + { + [Fact] + public void ParsesResizeAndClose() + { + string resizeUrl = "https://countly_action_event?cly_widget_command=1&action=resize_me&resize_me=" + + Uri.EscapeDataString("{\"p\":{\"x\":1,\"y\":2,\"w\":300,\"h\":400},\"l\":{\"x\":5,\"y\":6,\"w\":700,\"h\":500}}"); + WidgetAction a = WidgetActionParser.Parse(resizeUrl); + Assert.True(a.IsActionEvent); + Assert.True(a.HasResize); + Assert.Equal(300, a.Portrait.W); + Assert.Equal(400, a.Portrait.H); + Assert.Equal(700, a.Landscape.W); + Assert.False(a.Close); + + WidgetAction c = WidgetActionParser.Parse("https://countly_action_event?cly_widget_command=1&close=1"); + Assert.True(c.IsActionEvent); + Assert.True(c.Close); + + WidgetAction n = WidgetActionParser.Parse("https://example.com/feedback/nps?widget_id=w1"); + Assert.False(n.IsActionEvent); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs new file mode 100644 index 00000000..288b89b6 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using CountlySDK.CountlyCommon; +using CountlySDK.CountlyCommon.Helpers; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class WidgetUrlBuilderTests + { + [Fact] + public void BuildFeedbackWidgetUrl_ContainsRequiredParamsAndCustom() + { + var baseParams = new Dictionary { + { "app_key", "APPKEY" }, { "device_id", "DEV1" }, + { "sdk_name", "csharp" }, { "sdk_version", "9.9.9" }, { "av", "1.2.3" } + }; + var widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.survey }; + + string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly", widget, baseParams); + + Assert.StartsWith("https://s.count.ly/feedback/survey?", url); + Assert.Contains("widget_id=w1", url); + Assert.Contains("device_id=DEV1", url); + Assert.Contains("app_key=APPKEY", url); + Assert.Contains("platform=windows", url); + Assert.Contains("sdk_version=9.9.9", url); + Assert.Contains("app_version=1.2.3", url); + Assert.Contains("%22rw%22%3A1", url); // "rw":1 (resizable opt-in), url-encoded + Assert.Contains("%22tc%22%3A1", url); + } + } +} diff --git a/ui/Countly.UI.WebView2/CHANGELOG.md b/ui/Countly.UI.WebView2/CHANGELOG.md new file mode 100644 index 00000000..538a4b4e --- /dev/null +++ b/ui/Countly.UI.WebView2/CHANGELOG.md @@ -0,0 +1,10 @@ +## XX.XX.XX +* Initial release. +* Added "CountlyWebView.PresentFeedbackWidget(...)" for displaying Countly feedback widgets (Surveys, NPS, Ratings) in a WebView2 host: + * WPF overload: "PresentFeedbackWidget(System.Windows.Window owner, CountlyFeedbackWidget widget, Action onClosed = null)" + * WinForms overload: "PresentFeedbackWidget(System.Windows.Forms.IWin32Window owner, CountlyFeedbackWidget widget, Action onClosed = null)" + * Dynamically resizes the host window to the widget's requested size and auto-reports the result when the widget is closed. +* Added "CountlyWebView.EnableContentZone()" / "DisableContentZone()" for displaying Countly content (experimental) as a borderless, top-most WebView2 overlay positioned by the server on the primary screen (WPF). +* Added "WebView2Runtime.IsAvailable(out string version)" to detect the WebView2 Evergreen Runtime; when the runtime is missing, presentation is a graceful no-op. +* Targets .NET Framework 4.6.2 and .NET 8 (Windows). Depends on the "Countly" SDK package and "Microsoft.Web.WebView2". +* Requires the WebView2 Evergreen Runtime to be installed on the end-user machine. diff --git a/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj b/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj new file mode 100644 index 00000000..abea2e2d --- /dev/null +++ b/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj @@ -0,0 +1,20 @@ + + + net462;net8.0-windows + true + true + CountlySDK.UI + Countly.UI.WebView2 + latest + false + false + + + + + + + + + diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Content.cs b/ui/Countly.UI.WebView2/CountlyWebView.Content.cs new file mode 100644 index 00000000..3b618085 --- /dev/null +++ b/ui/Countly.UI.WebView2/CountlyWebView.Content.cs @@ -0,0 +1,28 @@ +namespace CountlySDK.UI +{ + public static partial class CountlyWebView + { + private static WebView2ContentDisplay _contentDisplay; + + /// + /// Enables the Countly content zone with a WebView2 overlay on the primary screen. + /// Call on the UI thread. No-op if the WebView2 runtime is unavailable. (Experimental.) + /// + public static void EnableContentZone(string[] categories = null) + { + if (!WebView2Runtime.IsAvailable(out _)) { + System.Diagnostics.Debug.WriteLine("[CountlyWebView] WebView2 runtime not available; content zone disabled"); + return; + } + _contentDisplay = new WebView2ContentDisplay(); + CountlySDK.Countly.Instance.SetContentDisplay(_contentDisplay); + CountlySDK.Countly.Instance.Content().EnterContentZone(categories); + } + + /// Disables the content zone (stops polling and prevents further overlays). + public static void DisableContentZone() + { + CountlySDK.Countly.Instance.Content().ExitContentZone(); + } + } +} diff --git a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs new file mode 100644 index 00000000..13a61e9c --- /dev/null +++ b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs @@ -0,0 +1,52 @@ +using System; +using System.Drawing; +using System.Windows.Forms; +using CountlySDK.CountlyCommon; +using Microsoft.Web.WebView2.WinForms; + +namespace CountlySDK.UI +{ + public static partial class CountlyWebView + { + /// + /// Presents a feedback widget in a WebView2-hosted WinForms form owned by + /// . Must be called on the UI thread. If the WebView2 runtime is + /// missing, this is a graceful no-op (logs and invokes ). + /// + public static void PresentFeedbackWidget(IWin32Window owner, CountlyFeedbackWidget widget, Action onClosed = null) + { + if (widget == null) { onClosed?.Invoke(); return; } + + if (!WebView2Runtime.IsAvailable(out _)) { + System.Diagnostics.Debug.WriteLine("[CountlyWebView] WebView2 runtime not available; cannot present widget"); + onClosed?.Invoke(); + return; + } + + Form form = new Form { + FormBorderStyle = FormBorderStyle.None, + StartPosition = FormStartPosition.CenterParent, + ClientSize = new Size(400, 600), + ShowInTaskbar = false + }; + WebView2 webView = new WebView2 { Dock = DockStyle.Fill }; + form.Controls.Add(webView); + + WinFormsWebView2WidgetHost adapter = new WinFormsWebView2WidgetHost(webView, form); + FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape: false, onClosed); + + form.Load += async (_, e) => { + try { + await adapter.InitializeAsync(); + await presenter.StartAsync(widget); + } catch (Exception ex) { + // async void: a display failure must never crash the host app. + System.Diagnostics.Debug.WriteLine("[CountlyWebView] feedback widget failed: " + ex); + try { form.Close(); } catch { /* best-effort close during teardown; not actionable */ } + onClosed?.Invoke(); + } + }; + form.Show(owner); + } + } +} diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs new file mode 100644 index 00000000..31bbdf79 --- /dev/null +++ b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs @@ -0,0 +1,55 @@ +using System; +using System.Windows; +using CountlySDK.CountlyCommon; +using Microsoft.Web.WebView2.Wpf; + +namespace CountlySDK.UI +{ + public static partial class CountlyWebView + { + /// + /// Presents a feedback widget in a WebView2-hosted WPF window owned by + /// . Must be called on the UI thread. If the WebView2 runtime is + /// missing, this is a graceful no-op (logs and invokes ). + /// + public static void PresentFeedbackWidget(Window owner, CountlyFeedbackWidget widget, Action onClosed = null) + { + if (widget == null) { onClosed?.Invoke(); return; } + + if (!WebView2Runtime.IsAvailable(out _)) { + System.Diagnostics.Debug.WriteLine("[CountlyWebView] WebView2 runtime not available; cannot present widget"); + onClosed?.Invoke(); + return; + } + + Window host = new Window { + Owner = owner, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Width = 400, + Height = 600, + ResizeMode = ResizeMode.NoResize, + WindowStyle = WindowStyle.None, + ShowInTaskbar = false + }; + WebView2 webView = new WebView2(); + host.Content = webView; + + WebView2WidgetHost adapter = new WebView2WidgetHost(webView, host); + bool isLandscape = owner != null && owner.ActualWidth >= owner.ActualHeight; + FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape, onClosed); + + host.Loaded += async (_, e) => { + try { + await adapter.InitializeAsync(); + await presenter.StartAsync(widget); + } catch (Exception ex) { + // async void: a display failure must never crash the host app. + System.Diagnostics.Debug.WriteLine("[CountlyWebView] feedback widget failed: " + ex); + try { host.Close(); } catch { /* best-effort close during teardown; not actionable */ } + onClosed?.Invoke(); + } + }; + host.Show(); + } + } +} diff --git a/ui/Countly.UI.WebView2/CountlyWebView.cs b/ui/Countly.UI.WebView2/CountlyWebView.cs new file mode 100644 index 00000000..1f3bfbe0 --- /dev/null +++ b/ui/Countly.UI.WebView2/CountlyWebView.cs @@ -0,0 +1,11 @@ +namespace CountlySDK.UI +{ + /// + /// Entry point for displaying Countly feedback widgets in a WebView2 host. + /// Platform-specific overloads (WPF Window, WinForms IWin32Window) are added + /// as partial definitions in the per-framework files. + /// + public static partial class CountlyWebView + { + } +} diff --git a/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs b/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs new file mode 100644 index 00000000..67b6d660 --- /dev/null +++ b/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; +using CountlySDK.CountlyCommon; + +namespace CountlySDK.UI +{ + /// + /// Drives a feedback widget in an : builds the display URL, + /// interprets the widget's countly_action_event navigations (resize / close), reports + /// the result, and notifies on close. Contains ALL the display logic and no WebView2 types, + /// so it is fully unit-testable with a fake host. + /// + public class FeedbackWidgetPresenter + { + private readonly IWidgetWebHost _host; + private readonly Feedback _feedback; + private readonly bool _isLandscape; + private readonly Action _onClosed; + private CountlyFeedbackWidget _widget; + + public FeedbackWidgetPresenter(IWidgetWebHost host, Feedback feedback, bool isLandscape, Action onClosed) + { + _host = host; + _feedback = feedback; + _isLandscape = isLandscape; + _onClosed = onClosed; + _host.NavigationStarting += OnNavigationStarting; + } + + /// Fetches the widget's display URL and navigates the host to it. + public async Task StartAsync(CountlyFeedbackWidget widget) + { + _widget = widget; + string url = await _feedback.ConstructFeedbackWidgetUrl(widget); + _host.Navigate(url); + } + + private void OnNavigationStarting(string url) + { + WidgetAction action = WidgetActionParser.Parse(url); + if (!action.IsActionEvent) { return; } + + if (action.HasResize) { + WidgetRect r = _isLandscape ? (action.Landscape ?? action.Portrait) : (action.Portrait ?? action.Landscape); + if (r != null) { _host.ResizeTo(r.W, r.H); } + } + + if (action.Close) { + // Report the widget as closed/cancelled (null result), then dismiss. + _ = _feedback.ReportFeedbackWidgetManually(_widget, null, null); + _host.CloseHost(); + _onClosed?.Invoke(); + } + } + } +} diff --git a/ui/Countly.UI.WebView2/IWidgetWebHost.cs b/ui/Countly.UI.WebView2/IWidgetWebHost.cs new file mode 100644 index 00000000..4c8e241b --- /dev/null +++ b/ui/Countly.UI.WebView2/IWidgetWebHost.cs @@ -0,0 +1,25 @@ +using System; + +namespace CountlySDK.UI +{ + /// + /// Abstraction over the embedded browser that hosts a widget. Keeps all presentation logic + /// in testable without instantiating a real WebView2 + /// (which requires a UI thread + the WebView2 runtime). The concrete adapters + /// (WebView2WidgetHost / WinFormsWebView2WidgetHost) are thin implementations. + /// + public interface IWidgetWebHost + { + /// Raised for each navigation the widget initiates; the argument is the target URI. + event Action NavigationStarting; + + /// Loads the given URL in the host. + void Navigate(string url); + + /// Resizes the owning window to the widget's requested content size (CSS px). + void ResizeTo(int cssWidth, int cssHeight); + + /// Closes/dismisses the host window. + void CloseHost(); + } +} diff --git a/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs new file mode 100644 index 00000000..9d4baade --- /dev/null +++ b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs @@ -0,0 +1,86 @@ +using System; +using System.Windows; +using System.Windows.Threading; +using CountlySDK.CountlyCommon; +using Microsoft.Web.WebView2.Wpf; + +namespace CountlySDK.UI +{ + /// + /// WPF implementation of the core bridge: reports the primary + /// screen size and shows server-placed content in a borderless, top-most, non-modal WebView2 + /// window at the given coordinates. Constructed on the UI thread (captures its Dispatcher). + /// + internal sealed class WebView2ContentDisplay : IContentDisplay + { + private readonly Dispatcher _dispatcher; + private readonly int _screenW, _screenH; + + public WebView2ContentDisplay() + { + _dispatcher = Dispatcher.CurrentDispatcher; // captured on the UI thread + _screenW = (int)SystemParameters.WorkArea.Width; + _screenH = (int)SystemParameters.WorkArea.Height; + } + + public ContentScreen GetScreen() + { + return new ContentScreen { Width = _screenW, Height = _screenH }; + } + + public void Present(ContentPlacement portrait, ContentPlacement landscape, string url, Action onClosed) + { + _dispatcher.BeginInvoke(new Action(() => { + try { + ContentPlacement r = (_screenW >= _screenH ? landscape : portrait) ?? portrait ?? landscape; + if (r == null) { onClosed?.Invoke(); return; } + + Window host = new Window { + WindowStyle = WindowStyle.None, + ResizeMode = ResizeMode.NoResize, + Topmost = true, + ShowInTaskbar = false, + ShowActivated = false, + WindowStartupLocation = WindowStartupLocation.Manual, + Left = r.X, Top = r.Y, Width = r.W, Height = r.H + }; + WebView2 webView = new WebView2(); + host.Content = webView; + + host.Loaded += async (_, e) => { + try { + await webView.EnsureCoreWebView2Async(null); + webView.CoreWebView2.NavigationStarting += (_, e2) => { + WidgetAction a = WidgetActionParser.Parse(e2.Uri); + if (!a.IsActionEvent) { return; } + + if (a.HasResize) { + WidgetRect rect = _screenW >= _screenH ? (a.Landscape ?? a.Portrait) : (a.Portrait ?? a.Landscape); + if (rect != null) { + host.Left = rect.X; host.Top = rect.Y; + host.Width = rect.W; host.Height = rect.H; + } + } + if (a.Close) { + e2.Cancel = true; + host.Close(); + onClosed?.Invoke(); + } + }; + webView.CoreWebView2.Navigate(url); // 'html' from the server is a URL + } catch (Exception ex) { + // async void: a display failure must never crash the host app. + System.Diagnostics.Debug.WriteLine("[CountlyWebView] content overlay failed: " + ex); + try { host.Close(); } catch { /* best-effort close during teardown; not actionable */ } + onClosed?.Invoke(); // return the module to a fetchable state so the zone is not wedged + } + }; + host.Show(); + } catch (Exception ex) { + System.Diagnostics.Debug.WriteLine("[CountlyWebView] content overlay failed to open: " + ex); + onClosed?.Invoke(); + } + })); + } + } +} diff --git a/ui/Countly.UI.WebView2/WebView2Runtime.cs b/ui/Countly.UI.WebView2/WebView2Runtime.cs new file mode 100644 index 00000000..20cfb27e --- /dev/null +++ b/ui/Countly.UI.WebView2/WebView2Runtime.cs @@ -0,0 +1,23 @@ +using Microsoft.Web.WebView2.Core; + +namespace CountlySDK.UI +{ + /// Detects whether the WebView2 Evergreen Runtime is available on the machine. + public static class WebView2Runtime + { + /// + /// Returns true if the WebView2 runtime is installed. receives + /// the installed version string, or null if unavailable. Never throws. + /// + public static bool IsAvailable(out string version) + { + version = null; + try { + version = CoreWebView2Environment.GetAvailableBrowserVersionString(); + return !string.IsNullOrEmpty(version); + } catch { + return false; + } + } + } +} diff --git a/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs b/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs new file mode 100644 index 00000000..48d17c00 --- /dev/null +++ b/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs @@ -0,0 +1,43 @@ +using System; +using System.Drawing; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.Web.WebView2.WinForms; + +namespace CountlySDK.UI +{ + /// + /// Thin WinForms adapter mapping onto a WebView2 control hosted + /// in a caller-owned . Logic-free by design. + /// + internal sealed class WinFormsWebView2WidgetHost : IWidgetWebHost + { + public event Action NavigationStarting; + + private readonly WebView2 _webView; + private readonly Form _form; + + public WinFormsWebView2WidgetHost(WebView2 webView, Form form) + { + _webView = webView; + _form = form; + } + + public async Task InitializeAsync() + { + await _webView.EnsureCoreWebView2Async(null); + _webView.CoreWebView2.NavigationStarting += (s, e) => NavigationStarting?.Invoke(e.Uri); + } + + public void Navigate(string url) { _webView.CoreWebView2.Navigate(url); } + + public void ResizeTo(int cssWidth, int cssHeight) + { + // WinForms is pixel-based; convert CSS px to device px for the form's current DPI. + float scale = _form.DeviceDpi / 96f; + _form.ClientSize = new Size((int)(cssWidth * scale), (int)(cssHeight * scale)); + } + + public void CloseHost() { _form.Close(); } + } +} diff --git a/ui/Countly.UI.WebView2/WebView2WidgetHost.cs b/ui/Countly.UI.WebView2/WebView2WidgetHost.cs new file mode 100644 index 00000000..c55ae9d8 --- /dev/null +++ b/ui/Countly.UI.WebView2/WebView2WidgetHost.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using System.Windows; +using Microsoft.Web.WebView2.Wpf; + +namespace CountlySDK.UI +{ + /// + /// Thin WPF adapter mapping onto a WebView2 control hosted in a + /// caller-owned . Intentionally logic-free — all behavior lives in + /// . + /// + internal sealed class WebView2WidgetHost : IWidgetWebHost + { + public event Action NavigationStarting; + + private readonly WebView2 _webView; + private readonly Window _window; + + public WebView2WidgetHost(WebView2 webView, Window window) + { + _webView = webView; + _window = window; + } + + public async Task InitializeAsync() + { + await _webView.EnsureCoreWebView2Async(null); + _webView.CoreWebView2.NavigationStarting += (s, e) => NavigationStarting?.Invoke(e.Uri); + } + + public void Navigate(string url) { _webView.CoreWebView2.Navigate(url); } + + public void ResizeTo(int cssWidth, int cssHeight) + { + // WPF sizes are device-independent units (96=1.0), which match WebView2's CSS px. + _window.Width = cssWidth; + _window.Height = cssHeight; + } + + public void CloseHost() { _window.Close(); } + } +} diff --git a/ui/CountlyFeedbackDemo.Wpf/App.xaml b/ui/CountlyFeedbackDemo.Wpf/App.xaml new file mode 100644 index 00000000..79409c89 --- /dev/null +++ b/ui/CountlyFeedbackDemo.Wpf/App.xaml @@ -0,0 +1,6 @@ + + + diff --git a/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs b/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs new file mode 100644 index 00000000..d5b1d65f --- /dev/null +++ b/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs @@ -0,0 +1,6 @@ +namespace CountlyFeedbackDemo.Wpf +{ + public partial class App : System.Windows.Application + { + } +} diff --git a/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj b/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj new file mode 100644 index 00000000..4117e2f1 --- /dev/null +++ b/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj @@ -0,0 +1,14 @@ + + + WinExe + net8.0-windows + true + disable + false + false + + + + + + diff --git a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml new file mode 100644 index 00000000..cdaadae1 --- /dev/null +++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml @@ -0,0 +1,16 @@ + + + + + + +