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 @@ + + + + + + +