From d61f0ff17bda08b981bf90480e9cb772fdba9f3b Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 16:52:39 +0900
Subject: [PATCH 1/5] Feedback widgets
---
CHANGELOG.md | 7 +
countlyCommon/countlyCommon/CountlyBase.cs | 26 ++++
.../Entities/CountlyFeedbackWidget.cs | 47 ++++++
.../Helpers/WidgetActionParser.cs | 69 +++++++++
.../countlyCommon/Helpers/WidgetUrlBuilder.cs | 37 +++++
.../countlyCommon/Modules/ModuleFeedback.cs | 143 ++++++++++++++++++
net35/Countly/Countly.csproj | 9 ++
net45/Countly/Countly.csproj | 9 ++
netstd/Countly/Countly.csproj | 3 +
ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs | 2 +
.../Countly.UI.WebView2.Tests.csproj | 22 +++
.../FeedbackWidgetParserTests.cs | 36 +++++
.../FeedbackWidgetPresenterTests.cs | 77 ++++++++++
.../LocalMockServer.cs | 72 +++++++++
.../ModuleFeedbackAccessorTests.cs | 34 +++++
.../ModuleFeedbackNetworkTests.cs | 79 ++++++++++
.../WebView2AndEntryPointTests.cs | 39 +++++
.../WidgetActionParserTests.cs | 30 ++++
.../WidgetUrlBuilderTests.cs | 32 ++++
ui/Countly.UI.WebView2/CHANGELOG.md | 9 ++
.../Countly.UI.WebView2.csproj | 20 +++
.../CountlyWebView.WinForms.cs | 45 ++++++
ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs | 48 ++++++
ui/Countly.UI.WebView2/CountlyWebView.cs | 11 ++
.../FeedbackWidgetPresenter.cs | 56 +++++++
ui/Countly.UI.WebView2/IWidgetWebHost.cs | 25 +++
ui/Countly.UI.WebView2/WebView2Runtime.cs | 23 +++
.../WebView2WidgetHost.WinForms.cs | 43 ++++++
ui/Countly.UI.WebView2/WebView2WidgetHost.cs | 43 ++++++
ui/CountlyFeedbackDemo.Wpf/App.xaml | 6 +
ui/CountlyFeedbackDemo.Wpf/App.xaml.cs | 6 +
.../CountlyFeedbackDemo.Wpf.csproj | 14 ++
ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml | 15 ++
ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs | 50 ++++++
34 files changed, 1187 insertions(+)
create mode 100644 countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
create mode 100644 countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs
create mode 100644 countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs
create mode 100644 countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj
create mode 100644 ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/LocalMockServer.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs
create mode 100644 ui/Countly.UI.WebView2/CHANGELOG.md
create mode 100644 ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj
create mode 100644 ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
create mode 100644 ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
create mode 100644 ui/Countly.UI.WebView2/CountlyWebView.cs
create mode 100644 ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs
create mode 100644 ui/Countly.UI.WebView2/IWidgetWebHost.cs
create mode 100644 ui/Countly.UI.WebView2/WebView2Runtime.cs
create mode 100644 ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs
create mode 100644 ui/Countly.UI.WebView2/WebView2WidgetHost.cs
create mode 100644 ui/CountlyFeedbackDemo.Wpf/App.xaml
create mode 100644 ui/CountlyFeedbackDemo.Wpf/App.xaml.cs
create mode 100644 ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj
create mode 100644 ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
create mode 100644 ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2301c9da..06064d79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@
* 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.
## 25.4.3
* ! Minor breaking change ! User properties will now be automatically saved under the following conditions:
diff --git a/countlyCommon/countlyCommon/CountlyBase.cs b/countlyCommon/countlyCommon/CountlyBase.cs
index d00505ba..4747d3d5 100644
--- a/countlyCommon/countlyCommon/CountlyBase.cs
+++ b/countlyCommon/countlyCommon/CountlyBase.cs
@@ -60,6 +60,7 @@ public enum LogLevel { VERBOSE, DEBUG, INFO, WARNING, ERROR };
internal CountlyConfig Configuration;
internal ModuleBackendMode moduleBackendMode;
internal ModuleRemoteConfig moduleRemoteConfig;
+ internal ModuleFeedback moduleFeedback;
public abstract string sdkName();
@@ -1210,6 +1211,7 @@ internal async Task HaltInternal(bool clearStorage = true)
// modules
moduleBackendMode = null;
moduleRemoteConfig = null;
+ moduleFeedback = null;
}
if (clearStorage) {
await ClearStorage();
@@ -1511,6 +1513,7 @@ protected async Task InitBase(CountlyConfig config)
}
moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl);
+ moduleFeedback = new ModuleFeedback(requestHelper, ServerUrl);
UtilityHelper.CountlyLogging("[CountlyBase] Finished 'InitBase'");
await OnInitComplete();
@@ -2011,5 +2014,28 @@ 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.backendMode) {
+ UtilityHelper.CountlyLogging("[CountlyBase] Feedback, backend mode is enabled, will omit this call");
+ return new MockFeedback();
+ }
+
+ if (moduleFeedback == null) {
+ return new MockFeedback();
+ }
+
+ if (!IsConsentGiven(ConsentFeatures.Feedback)) {
+ return new MockFeedback();
+ }
+
+ return moduleFeedback;
+ }
}
}
diff --git a/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
new file mode 100644
index 00000000..bfb24113
--- /dev/null
+++ b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
@@ -0,0 +1,47 @@
+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 (!System.Enum.TryParse(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();
+ }
+ }
+}
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/ModuleFeedback.cs b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
new file mode 100644
index 00000000..c248534c
--- /dev/null
+++ b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
@@ -0,0 +1,143 @@
+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
+ {
+ public Task GetAvailableFeedbackWidgets() { return Task.FromResult(new CountlyFeedbackWidget[0]); }
+ public Task GetFeedbackWidgetData(CountlyFeedbackWidget widget) { return Task.FromResult(null); }
+ public Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult) { return Task.FromResult(0); }
+ public Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget) { return Task.FromResult(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..c72b0008 100644
--- a/net35/Countly/Countly.csproj
+++ b/net35/Countly/Countly.csproj
@@ -78,6 +78,9 @@
Entities\CountlyEvent.cs
+
+ Entities\CountlyFeedbackWidget.cs
+
Entities\CountlyUserDetails.cs
@@ -123,6 +126,12 @@
Helpers\RequestHelper.cs
+
+ Helpers\WidgetActionParser.cs
+
+
+ Helpers\WidgetUrlBuilder.cs
+
Helpers\StorageBase.cs
diff --git a/net45/Countly/Countly.csproj b/net45/Countly/Countly.csproj
index 85a9eaf7..3b037462 100644
--- a/net45/Countly/Countly.csproj
+++ b/net45/Countly/Countly.csproj
@@ -78,6 +78,9 @@
Entities\CountlyEvent.cs
+
+ Entities\CountlyFeedbackWidget.cs
+
Entities\CountlyUserDetails.cs
@@ -123,6 +126,12 @@
Helpers\RequestHelper.cs
+
+ Helpers\WidgetActionParser.cs
+
+
+ Helpers\WidgetUrlBuilder.cs
+
Helpers\StorageBase.cs
diff --git a/netstd/Countly/Countly.csproj b/netstd/Countly/Countly.csproj
index 33c35d1d..5887a407 100644
--- a/netstd/Countly/Countly.csproj
+++ b/netstd/Countly/Countly.csproj
@@ -29,6 +29,7 @@
+
@@ -46,6 +47,8 @@
+
+
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/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..dcfabb30
--- /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 { } }
+
+ public sealed class Captured
+ {
+ public string RawUrl;
+ public string Method;
+ public string Body;
+ }
+ }
+}
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..a994a0b9
--- /dev/null
+++ b/ui/Countly.UI.WebView2/CHANGELOG.md
@@ -0,0 +1,9 @@
+## 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 "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.WinForms.cs b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
new file mode 100644
index 00000000..a9e44d89
--- /dev/null
+++ b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
@@ -0,0 +1,45 @@
+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 (s, e) => {
+ await adapter.InitializeAsync();
+ await presenter.StartAsync(widget);
+ };
+ 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..b2a0da98
--- /dev/null
+++ b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
@@ -0,0 +1,48 @@
+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 (s, e) => {
+ await adapter.InitializeAsync();
+ await presenter.StartAsync(widget);
+ };
+ 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/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..3894f53f
--- /dev/null
+++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
new file mode 100644
index 00000000..209f4910
--- /dev/null
+++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Linq;
+using System.Windows;
+using CountlySDK.CountlyCommon;
+using CountlySDK.Entities;
+using CountlySDK.UI;
+
+namespace CountlyFeedbackDemo.Wpf
+{
+ public partial class MainWindow : Window
+ {
+ private CountlyFeedbackWidget[] _widgets = new CountlyFeedbackWidget[0];
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ private async void InitBtn_Click(object sender, RoutedEventArgs e)
+ {
+ try {
+ CountlySDK.Countly.Halt();
+ CountlyConfig cc = new CountlyConfig {
+ serverUrl = ServerBox.Text.Trim(),
+ appKey = AppKeyBox.Text.Trim(),
+ appVersion = "1.0"
+ };
+ await CountlySDK.Countly.Instance.Init(cc);
+
+ _widgets = await CountlySDK.Countly.Instance.Feedback().GetAvailableFeedbackWidgets();
+ WidgetList.ItemsSource = _widgets.Select(w => $"{w.type} {w.name} ({w.widgetId})").ToArray();
+
+ bool rt = WebView2Runtime.IsAvailable(out string version);
+ StatusText.Text = $"Fetched {_widgets.Length} widget(s). WebView2 runtime: {(rt ? version : "NOT INSTALLED")}";
+ } catch (Exception ex) {
+ StatusText.Text = "Init/fetch failed: " + ex.Message;
+ }
+ }
+
+ private void ShowBtn_Click(object sender, RoutedEventArgs e)
+ {
+ int i = WidgetList.SelectedIndex;
+ if (i < 0 || i >= _widgets.Length) {
+ StatusText.Text = "Select a widget in the list first.";
+ return;
+ }
+ CountlyWebView.PresentFeedbackWidget(this, _widgets[i], () => StatusText.Text = "Widget closed.");
+ }
+ }
+}
From a73b05a58589d2366678228c9dafda099e6d2147 Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 19:36:16 +0900
Subject: [PATCH 2/5] 35 issue
---
.../Entities/CountlyFeedbackWidget.cs | 14 +++++++++++++-
.../countlyCommon/Modules/ModuleFeedback.cs | 9 +++++----
2 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
index bfb24113..98e2a0d2 100644
--- a/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
+++ b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs
@@ -27,7 +27,7 @@ public static CountlyFeedbackWidget[] ParseAvailableWidgets(string responseText)
foreach (JToken item in arr) {
string typeStr = (string)item["type"];
if (typeStr == null) { continue; }
- if (!System.Enum.TryParse(typeStr, out FeedbackWidgetType type)) { continue; }
+ if (!TryParseWidgetType(typeStr, out FeedbackWidgetType type)) { continue; }
CountlyFeedbackWidget w = new CountlyFeedbackWidget {
widgetId = (string)item["_id"],
@@ -43,5 +43,17 @@ public static CountlyFeedbackWidget[] ParseAvailableWidgets(string responseText)
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/Modules/ModuleFeedback.cs b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
index c248534c..534cea2f 100644
--- a/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
+++ b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs
@@ -113,10 +113,11 @@ public async Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widge
internal class MockFeedback : Feedback
{
- public Task GetAvailableFeedbackWidgets() { return Task.FromResult(new CountlyFeedbackWidget[0]); }
- public Task GetFeedbackWidgetData(CountlyFeedbackWidget widget) { return Task.FromResult(null); }
- public Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult) { return Task.FromResult(0); }
- public Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget) { return Task.FromResult(null); }
+ // 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; }
}
///
From 86ef33050b2164363f54b8cbe01c0563079e8b32 Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Thu, 2 Jul 2026 19:40:41 +0900
Subject: [PATCH 3/5] ci
---
.github/workflows/ci.yml | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
create mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..61bb6eea
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,25 @@
+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
From cca5eda490d412000936775df0f4079cbcb484ad Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Sat, 4 Jul 2026 22:24:00 +0900
Subject: [PATCH 4/5] content
---
.github/workflows/ci.yml | 17 ++
CHANGELOG.md | 8 +
countlyCommon/TestingRelated/ConsentTests.cs | 9 +-
countlyCommon/TestingRelated/DeviceIdTests.cs | 3 +-
countlyCommon/TestingRelated/TestHelper.cs | 8 +-
.../TestingRelated/UserDetailsTests.cs | 8 +-
countlyCommon/countlyCommon/CountlyBase.cs | 92 ++++++++---
.../countlyCommon/Entities/ContentModels.cs | 47 ++++++
.../Entities/CountlyConfigBase.cs | 13 ++
.../Helpers/ContentRequestBuilder.cs | 43 +++++
.../countlyCommon/Helpers/RequestHelper.cs | 3 +
.../countlyCommon/Modules/ModuleContent.cs | 152 ++++++++++++++++++
net35/Countly/Countly.csproj | 6 +
net45/Countly/Countly.csproj | 6 +
netstd/Countly/Countly.cs | 4 +-
netstd/Countly/Countly.csproj | 5 +
netstd/CountlyTest_461/CountlyTest_461.csproj | 2 +-
.../ContentConfigTests.cs | 19 +++
.../ContentEntryPointTests.cs | 27 ++++
.../ContentParserTests.cs | 31 ++++
.../ContentRequestBuilderTests.cs | 32 ++++
.../ModuleContentAccessorTests.cs | 50 ++++++
.../ModuleContentZoneTests.cs | 74 +++++++++
ui/Countly.UI.WebView2/CHANGELOG.md | 1 +
.../CountlyWebView.Content.cs | 28 ++++
.../CountlyWebView.WinForms.cs | 11 +-
ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs | 11 +-
.../WebView2ContentDisplay.cs | 86 ++++++++++
ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml | 1 +
ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs | 8 +-
30 files changed, 765 insertions(+), 40 deletions(-)
create mode 100644 countlyCommon/countlyCommon/Entities/ContentModels.cs
create mode 100644 countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs
create mode 100644 countlyCommon/countlyCommon/Modules/ModuleContent.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ContentParserTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs
create mode 100644 ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs
create mode 100644 ui/Countly.UI.WebView2/CountlyWebView.Content.cs
create mode 100644 ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 61bb6eea..9910f564 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,3 +23,20 @@ jobs:
-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 06064d79..6f9157b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,14 @@
* "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..356c8578 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, expected) => 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 4747d3d5..83bd4fd8 100644
--- a/countlyCommon/countlyCommon/CountlyBase.cs
+++ b/countlyCommon/countlyCommon/CountlyBase.cs
@@ -61,6 +61,7 @@ public enum LogLevel { VERBOSE, DEBUG, INFO, WARNING, ERROR };
internal ModuleBackendMode moduleBackendMode;
internal ModuleRemoteConfig moduleRemoteConfig;
internal ModuleFeedback moduleFeedback;
+ internal ModuleContent moduleContent;
public abstract string sdkName();
@@ -200,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();
@@ -295,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;
}
@@ -323,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;
}
@@ -1212,6 +1220,8 @@ internal async Task HaltInternal(bool clearStorage = true)
moduleBackendMode = null;
moduleRemoteConfig = null;
moduleFeedback = null;
+ if (moduleContent != null) { moduleContent.ExitContentZone(); } // stop the poll timer
+ moduleContent = null;
}
if (clearStorage) {
await ClearStorage();
@@ -1514,6 +1524,7 @@ 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();
@@ -1863,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;
}
}
}
@@ -1999,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();
}
@@ -2022,8 +2037,8 @@ public RemoteConfig RemoteConfig()
///
public Feedback Feedback()
{
- if (Configuration.backendMode) {
- UtilityHelper.CountlyLogging("[CountlyBase] Feedback, backend mode is enabled, will omit this call");
+ if (Configuration == null || Configuration.backendMode) {
+ UtilityHelper.CountlyLogging("[CountlyBase] Feedback, backend mode is enabled or SDK not initialized, will omit this call");
return new MockFeedback();
}
@@ -2037,5 +2052,30 @@ public Feedback Feedback()
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/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/Modules/ModuleContent.cs b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
new file mode 100644
index 00000000..c4987ecf
--- /dev/null
+++ b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
@@ -0,0 +1,152 @@
+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
+ {
+ 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 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/net35/Countly/Countly.csproj b/net35/Countly/Countly.csproj
index c72b0008..b6ccd80a 100644
--- a/net35/Countly/Countly.csproj
+++ b/net35/Countly/Countly.csproj
@@ -81,6 +81,9 @@
Entities\CountlyFeedbackWidget.cs
+
+ Entities\ContentModels.cs
+
Entities\CountlyUserDetails.cs
@@ -132,6 +135,9 @@
Helpers\WidgetUrlBuilder.cs
+
+ Helpers\ContentRequestBuilder.cs
+
Helpers\StorageBase.cs
diff --git a/net45/Countly/Countly.csproj b/net45/Countly/Countly.csproj
index 3b037462..136a9f17 100644
--- a/net45/Countly/Countly.csproj
+++ b/net45/Countly/Countly.csproj
@@ -81,6 +81,9 @@
Entities\CountlyFeedbackWidget.cs
+
+ Entities\ContentModels.cs
+
Entities\CountlyUserDetails.cs
@@ -132,6 +135,9 @@
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 5887a407..517fff35 100644
--- a/netstd/Countly/Countly.csproj
+++ b/netstd/Countly/Countly.csproj
@@ -23,6 +23,9 @@
embedded
+
+ $(DefineConstants);COUNTLY_TESTABLE
+
@@ -30,6 +33,7 @@
+
@@ -49,6 +53,7 @@
+
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/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/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/CHANGELOG.md b/ui/Countly.UI.WebView2/CHANGELOG.md
index a994a0b9..538a4b4e 100644
--- a/ui/Countly.UI.WebView2/CHANGELOG.md
+++ b/ui/Countly.UI.WebView2/CHANGELOG.md
@@ -4,6 +4,7 @@
* 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/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
index a9e44d89..283afc6e 100644
--- a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
+++ b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
@@ -36,8 +36,15 @@ public static void PresentFeedbackWidget(IWin32Window owner, CountlyFeedbackWidg
FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape: false, onClosed);
form.Load += async (s, e) => {
- await adapter.InitializeAsync();
- await presenter.StartAsync(widget);
+ 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 { }
+ onClosed?.Invoke();
+ }
};
form.Show(owner);
}
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
index b2a0da98..5b777d75 100644
--- a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
+++ b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
@@ -39,8 +39,15 @@ public static void PresentFeedbackWidget(Window owner, CountlyFeedbackWidget wid
FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape, onClosed);
host.Loaded += async (s, e) => {
- await adapter.InitializeAsync();
- await presenter.StartAsync(widget);
+ 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 { }
+ onClosed?.Invoke();
+ }
};
host.Show();
}
diff --git a/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
new file mode 100644
index 00000000..d01513a2
--- /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 (s, e) => {
+ try {
+ await webView.EnsureCoreWebView2Async(null);
+ webView.CoreWebView2.NavigationStarting += (s2, 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 { }
+ 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/CountlyFeedbackDemo.Wpf/MainWindow.xaml b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
index 3894f53f..cdaadae1 100644
--- a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
+++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml
@@ -10,6 +10,7 @@
+
diff --git a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
index 209f4910..e51f5612 100644
--- a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
+++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml.cs
@@ -26,7 +26,7 @@ private async void InitBtn_Click(object sender, RoutedEventArgs e)
appVersion = "1.0"
};
await CountlySDK.Countly.Instance.Init(cc);
-
+ await CountlySDK.Countly.Instance.SessionBegin();
_widgets = await CountlySDK.Countly.Instance.Feedback().GetAvailableFeedbackWidgets();
WidgetList.ItemsSource = _widgets.Select(w => $"{w.type} {w.name} ({w.widgetId})").ToArray();
@@ -46,5 +46,11 @@ private void ShowBtn_Click(object sender, RoutedEventArgs e)
}
CountlyWebView.PresentFeedbackWidget(this, _widgets[i], () => StatusText.Text = "Widget closed.");
}
+
+ private void ContentBtn_Click(object sender, RoutedEventArgs e)
+ {
+ CountlyWebView.EnableContentZone();
+ StatusText.Text = "Content zone enabled (polling). Server must have active content for this device.";
+ }
}
}
From 0f01071ef4b4c254e5aab5e0df79194ddad42a8b Mon Sep 17 00:00:00 2001
From: turtledreams <62231246+turtledreams@users.noreply.github.com>
Date: Sun, 5 Jul 2026 00:03:08 +0900
Subject: [PATCH 5/5] codacy
---
countlyCommon/TestingRelated/UserDetailsTests.cs | 2 +-
countlyCommon/countlyCommon/Modules/ModuleContent.cs | 9 ++++++++-
ui/Countly.UI.WebView2.Tests/LocalMockServer.cs | 2 +-
ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs | 4 ++--
ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs | 4 ++--
ui/Countly.UI.WebView2/WebView2ContentDisplay.cs | 6 +++---
6 files changed, 17 insertions(+), 10 deletions(-)
diff --git a/countlyCommon/TestingRelated/UserDetailsTests.cs b/countlyCommon/TestingRelated/UserDetailsTests.cs
index 356c8578..b2824d4c 100644
--- a/countlyCommon/TestingRelated/UserDetailsTests.cs
+++ b/countlyCommon/TestingRelated/UserDetailsTests.cs
@@ -237,7 +237,7 @@ public void SetUserDetails_SessionEventsTriggers()
// 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, expected) => Assert.True(int.Parse(actual) >= 2 && int.Parse(actual) <= 4, "session_duration was " + actual) }
+ { "session_duration", (actual, _) => Assert.True(int.Parse(actual) >= 2 && int.Parse(actual) <= 4, "session_duration was " + actual) }
});
server.Dispose();
diff --git a/countlyCommon/countlyCommon/Modules/ModuleContent.cs b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
index c4987ecf..7ee93f4a 100644
--- a/countlyCommon/countlyCommon/Modules/ModuleContent.cs
+++ b/countlyCommon/countlyCommon/Modules/ModuleContent.cs
@@ -8,7 +8,7 @@
namespace CountlySDK.CountlyCommon
{
- internal class ModuleContent : Content
+ internal class ModuleContent : Content, IDisposable
{
private readonly RequestHelper requestHelper;
private readonly string ServerUrl;
@@ -60,6 +60,13 @@ public void ExitContentZone()
}
}
+ 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();
diff --git a/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs b/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs
index dcfabb30..7a5d2769 100644
--- a/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs
+++ b/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs
@@ -60,7 +60,7 @@ private static int FreePort()
return p;
}
- public void Dispose() { try { _listener.Stop(); } catch { } }
+ public void Dispose() { try { _listener.Stop(); } catch { /* listener already stopped/disposed; nothing to do */ } }
public sealed class Captured
{
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
index 283afc6e..13a61e9c 100644
--- a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
+++ b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs
@@ -35,14 +35,14 @@ public static void PresentFeedbackWidget(IWin32Window owner, CountlyFeedbackWidg
WinFormsWebView2WidgetHost adapter = new WinFormsWebView2WidgetHost(webView, form);
FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape: false, onClosed);
- form.Load += async (s, e) => {
+ 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 { }
+ try { form.Close(); } catch { /* best-effort close during teardown; not actionable */ }
onClosed?.Invoke();
}
};
diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
index 5b777d75..31bbdf79 100644
--- a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
+++ b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs
@@ -38,14 +38,14 @@ public static void PresentFeedbackWidget(Window owner, CountlyFeedbackWidget wid
bool isLandscape = owner != null && owner.ActualWidth >= owner.ActualHeight;
FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), isLandscape, onClosed);
- host.Loaded += async (s, e) => {
+ 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 { }
+ try { host.Close(); } catch { /* best-effort close during teardown; not actionable */ }
onClosed?.Invoke();
}
};
diff --git a/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
index d01513a2..9d4baade 100644
--- a/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
+++ b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs
@@ -47,10 +47,10 @@ public void Present(ContentPlacement portrait, ContentPlacement landscape, strin
WebView2 webView = new WebView2();
host.Content = webView;
- host.Loaded += async (s, e) => {
+ host.Loaded += async (_, e) => {
try {
await webView.EnsureCoreWebView2Async(null);
- webView.CoreWebView2.NavigationStarting += (s2, e2) => {
+ webView.CoreWebView2.NavigationStarting += (_, e2) => {
WidgetAction a = WidgetActionParser.Parse(e2.Uri);
if (!a.IsActionEvent) { return; }
@@ -71,7 +71,7 @@ public void Present(ContentPlacement portrait, ContentPlacement landscape, strin
} 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 { }
+ 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
}
};