diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9910f564 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + branches: + - staging + +jobs: + feedback-ui-tests: + name: Feedback + UI unit tests (net8.0-windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Run unit tests + run: > + dotnet test ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj + -c Release + -p:SignAssembly=false + -p:DelaySign=false + -p:GenerateDocumentationFile=false + + core-build: + name: Build core (net35 / net45 / netstd) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: microsoft/setup-msbuild@v2 + + - name: Restore + build core targets + shell: pwsh + run: | + nuget restore net35/Countly.sln + nuget restore net45/Countly.sln + msbuild net35/Countly/Countly.csproj /t:Build /p:Configuration=Release /p:SignAssembly=false /p:DelaySign=false /p:GenerateDocumentationFile=false /v:minimal + msbuild net45/Countly/Countly.csproj /t:Build /p:Configuration=Release /p:SignAssembly=false /p:DelaySign=false /p:GenerateDocumentationFile=false /v:minimal + dotnet build netstd/Countly/Countly.csproj -c Release -p:SignAssembly=false -p:DelaySign=false -p:GenerateDocumentationFile=false diff --git a/.github/workflows/release-ui.yml b/.github/workflows/release-ui.yml new file mode 100644 index 00000000..5f169556 --- /dev/null +++ b/.github/workflows/release-ui.yml @@ -0,0 +1,61 @@ +name: Release UI package + +# Publishes the Countly.UI.WebView2 NuGet package. +# +# Trigger manually (Actions -> Release UI package -> Run workflow) or by pushing a tag like +# `ui-v26.1.0`. The package version comes from the csproj (), not the tag. +# +# PREREQUISITES: +# 1. The core `Countly` package at the version referenced by the UI csproj (26.1.0) must ALREADY +# be published to nuget.org — the release build restores it (`-p:UsePackagedCountly=true`). +# 2. Repo secrets: +# - NUGET_API_KEY : nuget.org push key +# - SIGNING_PFX_BASE64 : base64 of CountlyWinSDKStrongNameKey.pfx (strong-name key) +# - SIGNING_PFX_PASSWORD : password for that .pfx (omit/blank if the key is passwordless) + +on: + workflow_dispatch: + push: + tags: + - 'ui-v*' + +jobs: + release: + name: Pack + push Countly.UI.WebView2 + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore strong-name key + shell: pwsh + run: | + $pfx = "netstd/Countly/CountlyWinSDKStrongNameKey.pfx" + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String("${{ secrets.SIGNING_PFX_BASE64 }}")) + Write-Host "Wrote signing key to $pfx" + + - name: Pack (Release, signed, against the published core) + shell: pwsh + run: > + dotnet pack ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj + -c Release + -p:UsePackagedCountly=true + -p:SignAssembly=true + -p:DelaySign=false + -o ${{ github.workspace }}/artifacts + + - name: Push to NuGet + shell: pwsh + run: > + dotnet nuget push "${{ github.workspace }}/artifacts/*.nupkg" + --api-key ${{ secrets.NUGET_API_KEY }} + --source https://api.nuget.org/v3/index.json + --skip-duplicate + + - uses: actions/upload-artifact@v4 + with: + name: nupkg + path: ${{ github.workspace }}/artifacts/*.nupkg diff --git a/.gitignore b/.gitignore index 7f061eb6..560c7682 100644 --- a/.gitignore +++ b/.gitignore @@ -262,4 +262,5 @@ _Pvt_Extensions /nuget/lib/* nuget/nuget.exe -.DS_Store \ No newline at end of file +.DS_Store +docs/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2301c9da..6f9157b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ * After initialization finishes. * RemoteConfig consent is given. * After the device ID is changed to a different user. +* Added support for the Feedback Widgets feature (Surveys, NPS, Ratings) accessible through the "Countly.Instance.Feedback()" interface: + * "GetAvailableFeedbackWidgets" for fetching the list of available feedback widgets from the server + * "GetFeedbackWidgetData" for fetching a widget's definition (for building a custom UI) + * "ReportFeedbackWidgetManually" for reporting a widget result, or marking it closed + * "ConstructFeedbackWidgetUrl" for building a widget's display URL + * This feature uses "Feedback" consent (and "StarRating" consent for rating widgets). + * Needs "Countly.UI.WebView2" for displaying feedback widgets on WPF and WinForms. +* Added support for the experimental Content feature accessible through the "Countly.Instance.Content()" interface: + * "EnterContentZone" / "ExitContentZone" for starting and stopping periodic content fetching + * "RefreshContentZone" for forcing an immediate refresh + * "PreviewContent" for fetching and showing a specific content by id + * Added "ContentZoneTimerInterval" and a global content callback to the configuration object + * This feature uses "Content" consent. + * Needs "Countly.UI.WebView2" for displaying content on WPF. +* Fixed a bug where the request queue was not processed when automatic user property saving ("autoSendUserDetails") was disabled, so queued requests (for example from a manual session update) were never uploaded and could cause the SDK to loop indefinitely. ## 25.4.3 * ! Minor breaking change ! User properties will now be automatically saved under the following conditions: diff --git a/countlyCommon/TestingRelated/ConsentTests.cs b/countlyCommon/TestingRelated/ConsentTests.cs index 862efdbc..e681d751 100644 --- a/countlyCommon/TestingRelated/ConsentTests.cs +++ b/countlyCommon/TestingRelated/ConsentTests.cs @@ -195,7 +195,7 @@ public void TestConsentRequest() Assert.False(string.IsNullOrEmpty(collection.Get("t"))); JObject consentObj = JObject.Parse(collection.Get("consent")); - Assert.Equal(10, consentObj.Count); + Assert.Equal(11, consentObj.Count); Assert.False(consentObj.GetValue("push").ToObject()); Assert.True(consentObj.GetValue("users").ToObject()); Assert.False(consentObj.GetValue("views").ToObject()); @@ -206,6 +206,7 @@ public void TestConsentRequest() Assert.False(consentObj.GetValue("feedback").ToObject()); Assert.False(consentObj.GetValue("star-rating").ToObject()); Assert.False(consentObj.GetValue("remote-config").ToObject()); + Assert.False(consentObj.GetValue("content").ToObject()); } /// @@ -241,7 +242,7 @@ public void TestGiveIndividualConsents() NameValueCollection collection = HttpUtility.ParseQueryString(request.Request); JObject consentObj = JObject.Parse(collection.Get("consent")); - Assert.Equal(10, consentObj.Count); + Assert.Equal(11, consentObj.Count); Assert.False(consentObj.GetValue("push").ToObject()); Assert.True(consentObj.GetValue("users").ToObject()); Assert.False(consentObj.GetValue("views").ToObject()); @@ -252,6 +253,7 @@ public void TestGiveIndividualConsents() Assert.False(consentObj.GetValue("feedback").ToObject()); Assert.False(consentObj.GetValue("star-rating").ToObject()); Assert.False(consentObj.GetValue("remote-config").ToObject()); + Assert.False(consentObj.GetValue("content").ToObject()); Dictionary consentToRemove = new Dictionary(); consentToRemove.Add(ConsentFeatures.Crashes, false); @@ -265,7 +267,7 @@ public void TestGiveIndividualConsents() collection = HttpUtility.ParseQueryString(request.Request); consentObj = JObject.Parse(collection.Get("consent")); - Assert.Equal(10, consentObj.Count); + Assert.Equal(11, consentObj.Count); Assert.False(consentObj.GetValue("push").ToObject()); Assert.True(consentObj.GetValue("users").ToObject()); Assert.False(consentObj.GetValue("views").ToObject()); @@ -276,6 +278,7 @@ public void TestGiveIndividualConsents() Assert.False(consentObj.GetValue("feedback").ToObject()); Assert.False(consentObj.GetValue("star-rating").ToObject()); Assert.False(consentObj.GetValue("remote-config").ToObject()); + Assert.False(consentObj.GetValue("content").ToObject()); } } } diff --git a/countlyCommon/TestingRelated/DeviceIdTests.cs b/countlyCommon/TestingRelated/DeviceIdTests.cs index ae957ae9..219e4235 100644 --- a/countlyCommon/TestingRelated/DeviceIdTests.cs +++ b/countlyCommon/TestingRelated/DeviceIdTests.cs @@ -141,7 +141,7 @@ public async void TestConsent_ChangeDeviceIdWithoutMerge() collection = HttpUtility.ParseQueryString(request.Request); JObject consentObj = JObject.Parse(collection.Get("consent")); - Assert.Equal(10, consentObj.Count); + Assert.Equal(11, consentObj.Count); Assert.False(consentObj.GetValue("push").ToObject()); Assert.True(consentObj.GetValue("users").ToObject()); Assert.False(consentObj.GetValue("views").ToObject()); @@ -152,6 +152,7 @@ public async void TestConsent_ChangeDeviceIdWithoutMerge() Assert.False(consentObj.GetValue("feedback").ToObject()); Assert.False(consentObj.GetValue("star-rating").ToObject()); Assert.False(consentObj.GetValue("remote-config").ToObject()); + Assert.False(consentObj.GetValue("content").ToObject()); type = collection.Get("t"); Assert.False(string.IsNullOrEmpty(type)); diff --git a/countlyCommon/TestingRelated/TestHelper.cs b/countlyCommon/TestingRelated/TestHelper.cs index a708bb9c..19c9cf00 100644 --- a/countlyCommon/TestingRelated/TestHelper.cs +++ b/countlyCommon/TestingRelated/TestHelper.cs @@ -528,12 +528,16 @@ internal static void ValidateRequestInQueue(string deviceId, string appKey, IDic } } - internal static void ValidateRequest(Dictionary request, IDictionary paramaters) + internal static void ValidateRequest(Dictionary request, IDictionary paramaters, IDictionary> customValidators = null) { ValidateBaseParams(request, DEVICE_ID, APP_KEY, 0); Assert.Equal(11 + paramaters.Count, request.Count); // + rr foreach (KeyValuePair item in paramaters) { - Assert.Equal(item.Value.ToString(), request[item.Key]); + if (customValidators != null && customValidators.ContainsKey(item.Key)) { + customValidators[item.Key].Invoke(request[item.Key], item.Value); + } else { + Assert.Equal(item.Value.ToString(), request[item.Key]); + } } } diff --git a/countlyCommon/TestingRelated/UserDetailsTests.cs b/countlyCommon/TestingRelated/UserDetailsTests.cs index 897153ea..b2824d4c 100644 --- a/countlyCommon/TestingRelated/UserDetailsTests.cs +++ b/countlyCommon/TestingRelated/UserDetailsTests.cs @@ -232,7 +232,13 @@ public void SetUserDetails_SessionEventsTriggers() TestHelper.ValidateRequest(server.Requests[3].Params, TestHelper.Dict("user_details", TestHelper.Json("name", "John"))); Assert.Contains("Test1", server.Requests[4].Params["events"]); TestHelper.ValidateRequest(server.Requests[5].Params, TestHelper.Dict("user_details", TestHelper.Json("email", "Doe@doe.com"))); - TestHelper.ValidateRequest(server.Requests[6].Params, TestHelper.Dict("end_session", "1", "session_duration", 3)); + // session_duration here is wall-clock derived (~2.4s of sleeps), so it rounds to 2 or 3 + // depending on machine/CI speed. Validate its presence with a tolerant range instead of + // an exact value to avoid timing flakiness. + TestHelper.ValidateRequest(server.Requests[6].Params, TestHelper.Dict("end_session", "1", "session_duration", 3), + new Dictionary> { + { "session_duration", (actual, _) => Assert.True(int.Parse(actual) >= 2 && int.Parse(actual) <= 4, "session_duration was " + actual) } + }); server.Dispose(); } diff --git a/countlyCommon/countlyCommon/CountlyBase.cs b/countlyCommon/countlyCommon/CountlyBase.cs index d00505ba..83bd4fd8 100644 --- a/countlyCommon/countlyCommon/CountlyBase.cs +++ b/countlyCommon/countlyCommon/CountlyBase.cs @@ -60,6 +60,8 @@ public enum LogLevel { VERBOSE, DEBUG, INFO, WARNING, ERROR }; internal CountlyConfig Configuration; internal ModuleBackendMode moduleBackendMode; internal ModuleRemoteConfig moduleRemoteConfig; + internal ModuleFeedback moduleFeedback; + internal ModuleContent moduleContent; public abstract string sdkName(); @@ -199,7 +201,7 @@ internal enum ConsentChangedAction } public enum ConsentFeatures { - Sessions, Events, Location, Crashes, Users, Views, Push, Feedback, StarRating, RemoteConfig + Sessions, Events, Location, Crashes, Users, Views, Push, Feedback, StarRating, RemoteConfig, Content }; internal abstract Metrics GetSessionMetrics(); @@ -294,9 +296,11 @@ internal async Task Upload() { UtilityHelper.CountlyLogging("[CountlyBase] Calling 'Upload'"); bool success = false; - bool shouldContinue = false; - do { + // Iterative drain with a no-progress guard.The guard stops once a pass makes no progress, so no stuck queue can loop forever. + int previousPending = int.MaxValue; + + while (true) { if (deferUpload) { return true; } @@ -322,33 +326,38 @@ internal async Task Upload() success = await UploadUserDetails(); } - if (success && Configuration.autoSendUserDetails) { + // Drain the request queue in BOTH modes. + if (success) { success = await UploadStoredRequests(); } - if (success && !uploadInProgress) { - int sC, exC, evC, rC; - bool isChanged; + if (!success || uploadInProgress) { + UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, in progress or unsuccessful"); + break; + } - lock (sync) { - sC = Sessions.Count; - exC = Exceptions.Count; - evC = Events.Count; - rC = StoredRequests.Count; - isChanged = !Configuration.autoSendUserDetails && UserDetails.isChanged; // if the auto flushing UPs used, this should not work at all - } + int sC, exC, evC, rC; + bool isChanged; - UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, " + sC + " " + exC + " " + evC + " " + rC + " " + isChanged); + lock (sync) { + sC = Sessions.Count; + exC = Exceptions.Count; + evC = Events.Count; + rC = StoredRequests.Count; + isChanged = !Configuration.autoSendUserDetails && UserDetails.isChanged && IsConsentGiven(ConsentFeatures.Users); + } - if (sC > 0 || exC > 0 || evC > 0 || rC > 0 || isChanged) { - //work still needs to be done - return await Upload(); - } - } else { - UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, in progress"); + UtilityHelper.CountlyLogging("[CountlyBase] Upload, after one loop, " + sC + " " + exC + " " + evC + " " + rC + " " + isChanged); + + int pending = sC + exC + evC + rC + (isChanged ? 1 : 0); + if (pending == 0 || pending >= previousPending) { + // Everything drained, or a pass made no progress (an item can't be flushed) -> stop + // instead of looping forever; leftovers retry on the next Upload() trigger. + break; } - } while (success && shouldContinue); + previousPending = pending; + } return success; } @@ -1210,6 +1219,9 @@ internal async Task HaltInternal(bool clearStorage = true) // modules moduleBackendMode = null; moduleRemoteConfig = null; + moduleFeedback = null; + if (moduleContent != null) { moduleContent.ExitContentZone(); } // stop the poll timer + moduleContent = null; } if (clearStorage) { await ClearStorage(); @@ -1511,6 +1523,8 @@ protected async Task InitBase(CountlyConfig config) } moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl); + moduleFeedback = new ModuleFeedback(requestHelper, ServerUrl); + moduleContent = new ModuleContent(requestHelper, ServerUrl); UtilityHelper.CountlyLogging("[CountlyBase] Finished 'InitBase'"); await OnInitComplete(); @@ -1860,6 +1874,10 @@ private async Task ActionsOnConsentChanges(Dictionary upd await RemoteConfig().DownloadKeys(); } break; + case ConsentFeatures.Content: + //content consent removed -> stop the polling timer (and invalidate any in-flight fetch) + if (!isGiven && moduleContent != null) { moduleContent.ExitContentZone(); } + break; } } } @@ -1996,8 +2014,8 @@ public BackendMode BackendMode() /// public RemoteConfig RemoteConfig() { - if (Configuration.backendMode) { - UtilityHelper.CountlyLogging("[CountlyBase] RemoteConfig, backend mode is enabled, will omit this call"); + if (Configuration == null || Configuration.backendMode) { + UtilityHelper.CountlyLogging("[CountlyBase] RemoteConfig, backend mode is enabled or SDK not initialized, will omit this call"); return new MockRemoteConfig(); } @@ -2011,5 +2029,53 @@ public RemoteConfig RemoteConfig() return moduleRemoteConfig; } + + /// + /// Returns the Feedback interface for retrieving/displaying/reporting feedback widgets. + /// If backend mode is enabled, the module is unavailable, or Feedback consent is not + /// given, a no-op is returned instead. + /// + public Feedback Feedback() + { + if (Configuration == null || Configuration.backendMode) { + UtilityHelper.CountlyLogging("[CountlyBase] Feedback, backend mode is enabled or SDK not initialized, will omit this call"); + return new MockFeedback(); + } + + if (moduleFeedback == null) { + return new MockFeedback(); + } + + if (!IsConsentGiven(ConsentFeatures.Feedback)) { + return new MockFeedback(); + } + + return moduleFeedback; + } + + /// Registers the UI content-display bridge (ungated; no-op if uninitialized). + public void SetContentDisplay(IContentDisplay display) + { + if (moduleContent != null) { moduleContent.display = display; } + } + + /// + /// Returns the Content interface (experimental). A no-op is + /// returned when backend mode is enabled, the module is unavailable, or Content consent + /// is not given. + /// + public Content Content() + { + if (Configuration == null || Configuration.backendMode) { + return new MockContent(); + } + if (moduleContent == null) { + return new MockContent(); + } + if (!IsConsentGiven(ConsentFeatures.Content)) { + return new MockContent(); + } + return moduleContent; + } } } diff --git a/countlyCommon/countlyCommon/Entities/ContentModels.cs b/countlyCommon/countlyCommon/Entities/ContentModels.cs new file mode 100644 index 00000000..0cb47d49 --- /dev/null +++ b/countlyCommon/countlyCommon/Entities/ContentModels.cs @@ -0,0 +1,47 @@ +using Newtonsoft.Json.Linq; + +namespace CountlySDK.CountlyCommon +{ + public class ContentPlacement { public int X, Y, W, H; } + + public class ContentScreen { public int Width, Height; } + + public class ContentData + { + public string Url; + public ContentPlacement Portrait; + public ContentPlacement Landscape; + } + + public static class ContentParser + { + /// Parses a /o/sdk/content response; returns null if it has no usable content. + public static ContentData ParseContentResponse(string json) + { + if (string.IsNullOrEmpty(json)) { return null; } + try { + JObject root = JObject.Parse(json); + string url = (string)root["html"]; + JObject geo = root["geo"] as JObject; + if (string.IsNullOrEmpty(url) || geo == null) { return null; } + + ContentPlacement p = ToPlacement(geo["p"]); + ContentPlacement l = ToPlacement(geo["l"]); + if (p == null && l == null) { return null; } + + return new ContentData { Url = url, Portrait = p, Landscape = l }; + } catch { + return null; + } + } + + private static ContentPlacement ToPlacement(JToken t) + { + if (!(t is JObject o)) { return null; } + return new ContentPlacement { + X = (int?)o["x"] ?? 0, Y = (int?)o["y"] ?? 0, + W = (int?)o["w"] ?? 0, H = (int?)o["h"] ?? 0 + }; + } + } +} diff --git a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs index e63a9917..93f85520 100644 --- a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs +++ b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs @@ -51,6 +51,19 @@ public class CountlyConfigBase /// public int sessionUpdateInterval = 60; + private int _contentZoneTimerInterval = 30; + + /// + /// Content zone poll interval in seconds. Values <= 15 are ignored. Default 30. (Experimental.) + /// + public int ContentZoneTimerInterval { + get { return _contentZoneTimerInterval; } + set { if (value > 15) { _contentZoneTimerInterval = value; } } + } + + /// Optional callback invoked when a shown content item is closed. (Experimental.) + public System.Action GlobalContentCallback { get; set; } + // /// Maximum size of all string keys /// diff --git a/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs new file mode 100644 index 00000000..d5909975 --- /dev/null +++ b/countlyCommon/countlyCommon/Entities/CountlyFeedbackWidget.cs @@ -0,0 +1,62 @@ +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(); + // Server "wv" (widget version). Null/empty => legacy widget (no resize_me protocol). + public string widgetVersion; + } + + public static class FeedbackWidgetParser + { + public static CountlyFeedbackWidget[] ParseAvailableWidgets(string responseText) + { + List result = new List(); + if (string.IsNullOrEmpty(responseText)) { return result.ToArray(); } + + try { + JObject root = JObject.Parse(responseText); + if (!(root["result"] is JArray arr)) { return result.ToArray(); } + + foreach (JToken item in arr) { + string typeStr = (string)item["type"]; + if (typeStr == null) { continue; } + if (!TryParseWidgetType(typeStr, out FeedbackWidgetType type)) { continue; } + + CountlyFeedbackWidget w = new CountlyFeedbackWidget { + widgetId = (string)item["_id"], + type = type, + name = (string)item["name"], + widgetVersion = (string)item["wv"] + }; + if (item["tg"] is JArray tags) { + foreach (JToken t in tags) { w.tags.Add((string)t); } + } + if (!string.IsNullOrEmpty(w.widgetId)) { result.Add(w); } + } + } catch { /* malformed response -> empty list */ } + + return result.ToArray(); + } + + // net35-safe replacement for Enum.TryParse (which is .NET 4.0+). The enum member names + // are the exact server type strings. + private static bool TryParseWidgetType(string value, out FeedbackWidgetType type) + { + switch (value) { + case "survey": type = FeedbackWidgetType.survey; return true; + case "nps": type = FeedbackWidgetType.nps; return true; + case "rating": type = FeedbackWidgetType.rating; return true; + default: type = default(FeedbackWidgetType); return false; + } + } + } +} diff --git a/countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs b/countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs new file mode 100644 index 00000000..5dd06ec9 --- /dev/null +++ b/countlyCommon/countlyCommon/Helpers/ContentRequestBuilder.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace CountlySDK.CountlyCommon.Helpers +{ + /// + /// Builds the content-specific query params for a /o/sdk/content fetch (base params are added + /// by RequestHelper.BuildRequest). Public so it can be unit-tested directly. (Experimental.) + /// + public static class ContentRequestBuilder + { + public static IDictionary BuildParams(ContentScreen screen, string[] categories, + string language, string deviceType, string contentId) + { + int w = screen != null ? screen.Width : 0; + int h = screen != null ? screen.Height : 0; + // Desktop reports the same rect for both orientations. + string resolution = "{\"l\":{\"w\":" + w + ",\"h\":" + h + "},\"p\":{\"w\":" + w + ",\"h\":" + h + "}}"; + + IDictionary p = new Dictionary { + { "method", "queue" }, + { "resolution", resolution }, + { "category", CategoryList(categories) }, + { "la", language ?? "" }, + { "dt", deviceType ?? "desktop" } + }; + if (!string.IsNullOrEmpty(contentId)) { + p.Add("content_id", contentId); + p.Add("preview", "true"); + } + return p; + } + + private static string CategoryList(string[] categories) + { + if (categories == null || categories.Length == 0) { return "[]"; } + string[] escaped = new string[categories.Length]; + for (int i = 0; i < categories.Length; i++) { + escaped[i] = categories[i] == null ? "" : System.Uri.EscapeDataString(categories[i]); + } + return "[" + string.Join(", ", escaped) + "]"; + } + } +} diff --git a/countlyCommon/countlyCommon/Helpers/RequestHelper.cs b/countlyCommon/countlyCommon/Helpers/RequestHelper.cs index 8f88ebb1..fb565016 100644 --- a/countlyCommon/countlyCommon/Helpers/RequestHelper.cs +++ b/countlyCommon/countlyCommon/Helpers/RequestHelper.cs @@ -101,6 +101,9 @@ internal string CreateConsentUpdateRequest(Dictionary upd case ConsentFeatures.RemoteConfig: consentChanges += "\"remote-config\":" + (value ? "true" : "false"); break; + case ConsentFeatures.Content: + consentChanges += "\"content\":" + (value ? "true" : "false"); + break; default: consentChanges += "\"unknown\":false"; break; diff --git a/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs b/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs new file mode 100644 index 00000000..70a1218b --- /dev/null +++ b/countlyCommon/countlyCommon/Helpers/WidgetActionParser.cs @@ -0,0 +1,101 @@ +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; + // Content action-event extras (cly_x_action_event=1&action=link|event ...). + public string Link; // action=link: URL to open externally + public string EventPayload; // action=event: raw JSON array of {key, segmentation|sg} + } + + 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 */ } + } + if (q.TryGetValue("link", out string link) && !string.IsNullOrEmpty(link)) { + // A close carried inside the link's own query (e.g. link=https://x?close=1) is a + // content signal, not part of the destination: close=1 means "close after opening". + Dictionary linkQuery = ParseQuery(link); + if (!action.Close && linkQuery.TryGetValue("close", out string linkClose) && linkClose == "1") { + action.Close = true; + } + // Strip the close signal so we open the clean destination URL. + action.Link = StripParam(link, "close"); + } + if (q.TryGetValue("event", out string ev) && !string.IsNullOrEmpty(ev)) { + action.EventPayload = ev; // raw JSON array; parsed/recorded by the content module + } + 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 + }; + } + + /// Returns the URL with the given query parameter removed (base URL if none remain). + private static string StripParam(string url, string name) + { + int qi = url.IndexOf('?'); + if (qi < 0) { return url; } + string basePart = url.Substring(0, qi); + List kept = new List(); + foreach (string pair in url.Substring(qi + 1).Split('&')) { + int eq = pair.IndexOf('='); + string key = eq > 0 ? pair.Substring(0, eq) : pair; + if (key == name) { continue; } + kept.Add(pair); + } + return kept.Count > 0 ? basePart + "?" + string.Join("&", kept.ToArray()) : basePart; + } + + 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..bb967367 --- /dev/null +++ b/countlyCommon/countlyCommon/Helpers/WidgetUrlBuilder.cs @@ -0,0 +1,56 @@ +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. Desktop follows the web-SDK model: custom={"tc":1,"xb":1} + /// (no rw fullscreen flag) plus origin, so the widget renders a positioned card and + /// reports its rect via resize_me; the SDK reports the surface size and places the window there. + /// + 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("?"); + // Uri.EscapeDataString throws on null; a null widgetId is a caller error, not a crash. + 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"); + // Desktop = web-SDK model: no rw (fullscreen); xb = widget draws its own close button. + sb.Append("&custom=").Append(Uri.EscapeDataString("{\"tc\":1,\"xb\":1}")); + // The widget only accepts our post-load {type:'resize'} message if its `origin` matches + // the page's own origin (the widget page validates event.origin === ?origin= param). + // Without this the resize message is dropped and a fullscreen widget has no dimensions + // to fill to (renders as a fixed content-sized card). Sent unencoded, like the web SDK. + string origin = OriginOf(serverUrl); + if (origin != null) { sb.Append("&origin=").Append(origin); } + return sb.ToString(); + } + + /// Scheme+authority of the server URL (e.g. "https://host:port"), or null if unparseable. + private static string OriginOf(string serverUrl) + { + try { + return new Uri(serverUrl, UriKind.Absolute).GetLeftPart(UriPartial.Authority); + } catch { + return null; + } + } + + private static void Append(StringBuilder sb, string outKey, IDictionary src, string srcKey) + { + if (src != null && src.TryGetValue(srcKey, out object v) && v != null) { + sb.Append("&").Append(outKey).Append("=").Append(Uri.EscapeDataString(Convert.ToString(v))); + } + } + } +} diff --git a/countlyCommon/countlyCommon/Modules/ModuleContent.cs b/countlyCommon/countlyCommon/Modules/ModuleContent.cs new file mode 100644 index 00000000..1429df7e --- /dev/null +++ b/countlyCommon/countlyCommon/Modules/ModuleContent.cs @@ -0,0 +1,211 @@ +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 ModuleContent : Content, IDisposable + { + private readonly RequestHelper requestHelper; + private readonly string ServerUrl; + internal IContentDisplay display; // set via Countly.Instance.SetContentDisplay(...) + + // Fully-qualified System.Threading.Timer to avoid the netstd CountlySDK.Helpers.TimerCallback collision. + private System.Threading.Timer _timer; + private readonly object _lock = new object(); + private bool _shouldFetch; + private bool _inZone; + private int _waitForDelay; + private volatile bool _fetching; + private int _generation; // bumped on ExitContentZone so an in-flight fetch can detect it was cancelled + private string[] _categories; // remembered so RefreshContentZone keeps the caller's category filter + 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; + _categories = categories; // remember for RefreshContentZone + int periodMs = Math.Max(1000, Countly.Instance.Configuration.ContentZoneTimerInterval * 1000); + if (_timer == null) { + _timer = new System.Threading.Timer(OnTick, categories, StartDelayMs, periodMs); + } else { + _timer.Change(StartDelayMs, periodMs); + } + } + } + + public void ExitContentZone() + { + lock (_lock) { + _shouldFetch = false; + _inZone = false; + _waitForDelay = 0; + _generation++; // invalidate any fetch already in flight so it won't present after we exit + if (_timer != null) { _timer.Dispose(); _timer = null; } + } + } + + public void Dispose() + { + // Standard disposal entry point (satisfies CA1001 for the owned System.Threading.Timer); + // ExitContentZone stops polling and disposes the timer. + ExitContentZone(); + } + + public void RefreshContentZone() + { + string[] categories = _categories; // capture before ExitContentZone so the filter survives the refresh + ExitContentZone(); + EnterContentZone(categories); + } + + public void PreviewContent(string contentId) + { + if (display == null || string.IsNullOrEmpty(contentId)) { return; } + lock (_lock) { + // Respect the shared fetch guard so a preview can't run concurrently with a timer + // fetch (which would stack two overlay windows on screen). FetchAndPresent's finally + // clears _fetching. + if (_inZone || _fetching) { return; } + _fetching = true; + } + 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(); } + } + + // Records events the content emitted (action=event) and force-flushes the queue so the + // server can react immediately (journey processing). eventJson is a raw JSON array of + // { key, segmentation|sg } objects. Called by the content display on an action-event. + public void RecordContentEvents(string eventJson) + { + if (string.IsNullOrEmpty(eventJson)) { return; } + try { + JArray arr = JArray.Parse(eventJson); + bool recorded = false; + foreach (JToken t in arr) { + string key = (string)t["key"]; + if (string.IsNullOrEmpty(key)) { continue; } + Segmentation seg = new Segmentation(); + JToken sg = t["sg"] ?? t["segmentation"]; // "sg" takes precedence + if (sg is JObject sgObj) { + foreach (JProperty p in sgObj.Properties()) { + seg.Add(p.Name, TokenToInvariantString(p.Value)); + } + } + _ = Countly.RecordEvent(key, 1, seg); + recorded = true; + } + if (recorded) { _ = Countly.Instance.Upload(); } + } catch (Exception ex) { + UtilityHelper.CountlyLogging("[ModuleContent] RecordContentEvents failed: " + ex.Message, LogLevel.ERROR); + } + } + + // Stringifies a segmentation value locale-independently: JToken.ToString() formats numbers + // with the current culture (e.g. "4,5" on de-DE), which would corrupt the wire value. + private static string TokenToInvariantString(JToken t) + { + if (t == null || t.Type == JTokenType.Null) { return null; } + switch (t.Type) { + case JTokenType.Float: return ((double)t).ToString(System.Globalization.CultureInfo.InvariantCulture); + case JTokenType.Integer: return ((long)t).ToString(System.Globalization.CultureInfo.InvariantCulture); + default: return t.ToString(); // strings/bools are already invariant + } + } + } + + internal class MockContent : Content + { + public void EnterContentZone(string[] categories = null) { } + public void ExitContentZone() { } + public void RefreshContentZone() { } + public void PreviewContent(string contentId) { } + public void RecordContentEvents(string eventJson) { } + } + + /// Retrieves and displays Countly content (experimental). + public interface Content + { + void EnterContentZone(string[] categories = null); + void ExitContentZone(); + void RefreshContentZone(); + void PreviewContent(string contentId); + void RecordContentEvents(string eventJson); + } + + /// + /// Bridge the UI package implements so the UI-less core can size the fetch to the screen and + /// display server-placed content. Registered via Countly.Instance.SetContentDisplay(...). + /// + public interface IContentDisplay + { + ContentScreen GetScreen(); + void Present(ContentPlacement portrait, ContentPlacement landscape, string url, Action onClosed); + } +} diff --git a/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs new file mode 100644 index 00000000..9d5b0391 --- /dev/null +++ b/countlyCommon/countlyCommon/Modules/ModuleFeedback.cs @@ -0,0 +1,149 @@ +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) { + // InvariantCulture: a numeric answer (e.g. 4.5) must not pick up a locale decimal + // separator ("4,5") on the wire, which would corrupt server-side aggregation. + segmentation.Add(kv.Key, Convert.ToString(kv.Value, System.Globalization.CultureInfo.InvariantCulture)); + } + } + + // 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 || string.IsNullOrEmpty(widget.widgetId)) { + UtilityHelper.CountlyLogging("[ModuleFeedback] ConstructFeedbackWidgetUrl, widget or widgetId is null/empty", LogLevel.ERROR); + return null; + } + IDictionary baseParams = await requestHelper.GetBaseParams(); + return WidgetUrlBuilder.BuildFeedbackWidgetUrl(ServerUrl, widget, baseParams); + } + } + + internal class MockFeedback : Feedback + { + // async-empty (no Task.FromResult, which is unavailable on net35) — mirrors MockRemoteConfig. + public async Task GetAvailableFeedbackWidgets() { return new CountlyFeedbackWidget[0]; } + public async Task GetFeedbackWidgetData(CountlyFeedbackWidget widget) { return null; } + public async Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult) { } + public async Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget) { return null; } + } + + /// + /// Defines the contract for retrieving, displaying, and reporting Countly feedback widgets + /// (Surveys, NPS, Ratings). + /// + public interface Feedback + { + /// Fetches the list of feedback widgets available for the current device. + Task GetAvailableFeedbackWidgets(); + + /// Fetches the raw widget definition JSON (for building a custom UI). + Task GetFeedbackWidgetData(CountlyFeedbackWidget widget); + + /// + /// Reports a widget result manually. Pass as null to + /// mark the widget closed/cancelled without an answer. + /// + Task ReportFeedbackWidgetManually(CountlyFeedbackWidget widget, JObject widgetData, Dictionary widgetResult); + + /// Builds the display URL for a widget (used by the WebView2 UI package). + Task ConstructFeedbackWidgetUrl(CountlyFeedbackWidget widget); + } +} diff --git a/net35/Countly/Countly.csproj b/net35/Countly/Countly.csproj index cee3cd53..b6ccd80a 100644 --- a/net35/Countly/Countly.csproj +++ b/net35/Countly/Countly.csproj @@ -78,6 +78,12 @@ Entities\CountlyEvent.cs + + Entities\CountlyFeedbackWidget.cs + + + Entities\ContentModels.cs + Entities\CountlyUserDetails.cs @@ -123,6 +129,15 @@ Helpers\RequestHelper.cs + + Helpers\WidgetActionParser.cs + + + Helpers\WidgetUrlBuilder.cs + + + Helpers\ContentRequestBuilder.cs + Helpers\StorageBase.cs diff --git a/net45/Countly/Countly.csproj b/net45/Countly/Countly.csproj index 85a9eaf7..136a9f17 100644 --- a/net45/Countly/Countly.csproj +++ b/net45/Countly/Countly.csproj @@ -78,6 +78,12 @@ Entities\CountlyEvent.cs + + Entities\CountlyFeedbackWidget.cs + + + Entities\ContentModels.cs + Entities\CountlyUserDetails.cs @@ -123,6 +129,15 @@ Helpers\RequestHelper.cs + + Helpers\WidgetActionParser.cs + + + Helpers\WidgetUrlBuilder.cs + + + Helpers\ContentRequestBuilder.cs + Helpers\StorageBase.cs diff --git a/netstd/Countly/Countly.cs b/netstd/Countly/Countly.cs index d2508195..4fd516ce 100644 --- a/netstd/Countly/Countly.cs +++ b/netstd/Countly/Countly.cs @@ -29,7 +29,9 @@ THE SOFTWARE. using CountlySDK.Entities; using CountlySDK.Entities.EntityBase; using CountlySDK.Helpers; -//[assembly: InternalsVisibleTo("CountlyTest_461")] +#if COUNTLY_TESTABLE +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("CountlyTest_461")] +#endif //[assembly: InternalsVisibleTo("CountlySampleUWP")] namespace CountlySDK diff --git a/netstd/Countly/Countly.csproj b/netstd/Countly/Countly.csproj index 33c35d1d..517fff35 100644 --- a/netstd/Countly/Countly.csproj +++ b/netstd/Countly/Countly.csproj @@ -23,12 +23,17 @@ embedded + + $(DefineConstants);COUNTLY_TESTABLE + + + @@ -46,6 +51,9 @@ + + + diff --git a/netstd/CountlyTest_461/CountlyTest_461.csproj b/netstd/CountlyTest_461/CountlyTest_461.csproj index ed577f3b..f8cd36c4 100644 --- a/netstd/CountlyTest_461/CountlyTest_461.csproj +++ b/netstd/CountlyTest_461/CountlyTest_461.csproj @@ -11,7 +11,7 @@ Properties CountlyTest_461 CountlyTest_461 - v4.6.1 + v4.6.2 512 true diff --git a/ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs b/ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..679b372e --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/AssemblyInfo.cs @@ -0,0 +1,2 @@ +// Init-based tests drive the shared static Countly singleton, so serialize the whole assembly. +[assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)] diff --git a/ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs b/ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs new file mode 100644 index 00000000..b9f50ef6 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ContentConfigTests.cs @@ -0,0 +1,19 @@ +using CountlySDK.Entities; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class ContentConfigTests + { + [Fact] + public void ZoneTimerInterval_DefaultsTo30_AndRejectsTooSmall() + { + CountlyConfig cc = new CountlyConfig(); + Assert.Equal(30, cc.ContentZoneTimerInterval); + cc.ContentZoneTimerInterval = 10; // <=15 ignored + Assert.Equal(30, cc.ContentZoneTimerInterval); + cc.ContentZoneTimerInterval = 45; + Assert.Equal(45, cc.ContentZoneTimerInterval); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs b/ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs new file mode 100644 index 00000000..83ea923b --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ContentEntryPointTests.cs @@ -0,0 +1,27 @@ +using System; +using CountlySDK.CountlyCommon; +using CountlySDK.UI; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + // The live overlay needs a UI thread + WebView2 runtime, so it's exercised via the demo. + // Here we assert the entry points exist and the bridge type implements IContentDisplay. + public class ContentEntryPointTests + { + [Fact] + public void EnableDisableContentZone_SignaturesExist() + { + Assert.NotNull(typeof(CountlyWebView).GetMethod("EnableContentZone", new[] { typeof(string[]) })); + Assert.NotNull(typeof(CountlyWebView).GetMethod("DisableContentZone", Type.EmptyTypes)); + } + + [Fact] + public void WebView2ContentDisplay_ImplementsBridge() + { + Type t = typeof(CountlyWebView).Assembly.GetType("CountlySDK.UI.WebView2ContentDisplay"); + Assert.NotNull(t); + Assert.True(typeof(IContentDisplay).IsAssignableFrom(t)); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ContentParserTests.cs b/ui/Countly.UI.WebView2.Tests/ContentParserTests.cs new file mode 100644 index 00000000..1bcaf052 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ContentParserTests.cs @@ -0,0 +1,31 @@ +using CountlySDK.CountlyCommon; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class ContentParserTests + { + [Fact] + public void ParsesGeoAndHtmlUrl() + { + string json = "{\"geo\":{\"p\":{\"x\":1,\"y\":2,\"w\":300,\"h\":400},\"l\":{\"x\":5,\"y\":6,\"w\":700,\"h\":500}},\"html\":\"https://s/content/abc\"}"; + ContentData c = ContentParser.ParseContentResponse(json); + Assert.NotNull(c); + Assert.Equal("https://s/content/abc", c.Url); + Assert.Equal(300, c.Portrait.W); + Assert.Equal(400, c.Portrait.H); + Assert.Equal(700, c.Landscape.W); + Assert.Equal(6, c.Landscape.Y); + } + + [Fact] + public void InvalidOrEmpty_ReturnsNull() + { + Assert.Null(ContentParser.ParseContentResponse("")); + Assert.Null(ContentParser.ParseContentResponse("{}")); + Assert.Null(ContentParser.ParseContentResponse("{\"geo\":{},\"html\":\"u\"}")); + Assert.Null(ContentParser.ParseContentResponse("{\"jsonArray\":[]}")); + Assert.Null(ContentParser.ParseContentResponse("not json")); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs b/ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs new file mode 100644 index 00000000..166895b3 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ContentRequestBuilderTests.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using CountlySDK.CountlyCommon; +using CountlySDK.CountlyCommon.Helpers; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class ContentRequestBuilderTests + { + [Fact] + public void BuildParams_IncludesResolutionMethodAndLocale() + { + var screen = new ContentScreen { Width = 1920, Height = 1080 }; + IDictionary p = ContentRequestBuilder.BuildParams(screen, new[] { "cat1" }, "en", "desktop", null); + Assert.Equal("queue", p["method"]); + Assert.Equal("en", p["la"]); + Assert.Equal("desktop", p["dt"]); + Assert.Contains("1920", (string)p["resolution"]); + Assert.Contains("\"w\"", (string)p["resolution"]); + Assert.False(p.ContainsKey("content_id")); + } + + [Fact] + public void BuildParams_Preview_AddsContentIdAndPreview() + { + var screen = new ContentScreen { Width = 800, Height = 600 }; + IDictionary p = ContentRequestBuilder.BuildParams(screen, null, "en", "desktop", "cid1"); + Assert.Equal("cid1", p["content_id"]); + Assert.Equal("true", p["preview"]); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj b/ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj new file mode 100644 index 00000000..bb90ea72 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/Countly.UI.WebView2.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0-windows + true + true + disable + false + false + false + + + + + + + + + + + + diff --git a/ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs new file mode 100644 index 00000000..4ab4f1d2 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetParserTests.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using CountlySDK.CountlyCommon; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class FeedbackWidgetParserTests + { + [Fact] + public void ParseAvailableWidgets_ParsesAllTypes() + { + string json = "{\"result\":[" + + "{\"_id\":\"id_s\",\"type\":\"survey\",\"name\":\"S\",\"tg\":[\"/a\"]}," + + "{\"_id\":\"id_n\",\"type\":\"nps\",\"name\":\"N\",\"tg\":[]}," + + "{\"_id\":\"id_r\",\"type\":\"rating\",\"name\":\"R\",\"tg\":[\"/b\",\"/c\"]}]}"; + + CountlyFeedbackWidget[] widgets = FeedbackWidgetParser.ParseAvailableWidgets(json); + + Assert.Equal(3, widgets.Length); + Assert.Equal("id_s", widgets[0].widgetId); + Assert.Equal(FeedbackWidgetType.survey, widgets[0].type); + Assert.Equal("N", widgets[1].name); + Assert.Equal(FeedbackWidgetType.nps, widgets[1].type); + Assert.Equal(new List { "/b", "/c" }, widgets[2].tags); + } + + [Fact] + public void ParseAvailableWidgets_InvalidOrEmpty_ReturnsEmpty() + { + Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("")); + Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("{}")); + Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("{\"result\":\"nope\"}")); + Assert.Empty(FeedbackWidgetParser.ParseAvailableWidgets("not json")); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs new file mode 100644 index 00000000..347b8a4d --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/FeedbackWidgetPresenterTests.cs @@ -0,0 +1,141 @@ +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 event Action ResizeRequested; + public event Action PageLoaded; + public event Action LoadFailed; + public WidgetSurface Surface { get; set; } = new WidgetSurface { X = 0, Y = 0, Width = 1920, Height = 1080 }; + public string NavigatedUrl; + public (int w, int h)? ReportedSize; + public WidgetRect Placed; + public bool Closed; + + public void Navigate(string url) { NavigatedUrl = url; } + public void ReportSurfaceSize(int w, int h) { ReportedSize = (w, h); } + public void PlaceAndShow(WidgetRect r) { Placed = r; } + public void CloseHost() { Closed = true; } + + public void FireNav(string url) { NavigationStarting?.Invoke(url); } + public void FireResize(WidgetAction a) { ResizeRequested?.Invoke(a); } + public void FirePageLoaded() { PageLoaded?.Invoke(); } + public void FireLoadFailed() { LoadFailed?.Invoke(); } + } + + internal sealed class FakeFeedback : Feedback + { + public bool ReportedClose; + public bool NullUrl; // simulate an unavailable widget (e.g. MockFeedback returns null) + 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(NullUrl ? null : "https://s/feedback/nps?widget_id=" + w.widgetId); + } + } + + public class FeedbackWidgetPresenterTests + { + [Fact] + public async Task NavigatesOnStart_ReportsSurfaceOnPageLoad() + { + FakeHost host = new FakeHost { Surface = new WidgetSurface { Width = 1920, Height = 1080 } }; + FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), () => { }); + await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }); + Assert.Equal("https://s/feedback/nps?widget_id=w1", host.NavigatedUrl); + Assert.Null(host.ReportedSize); // not until the page loads + + host.FirePageLoaded(); + Assert.Equal((1920, 1080), host.ReportedSize); + + host.FirePageLoaded(); // report once only + Assert.Equal((1920, 1080), host.ReportedSize); + } + + [Fact] + public async Task ResizeRequested_PlacesCardAtResolvedRect() + { + FakeHost host = new FakeHost { Surface = new WidgetSurface { X = 0, Y = 0, Width = 1920, Height = 1080 } }; + FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), () => { }); + await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }); + + host.FireResize(new WidgetAction { + IsActionEvent = true, HasResize = true, + Landscape = new WidgetRect { X = 1420, Y = 460, W = 500, H = 620 }, + Portrait = new WidgetRect { X = 0, Y = 0, W = 300, H = 400 } + }); + + Assert.Equal(1420, host.Placed.X); + Assert.Equal(500, host.Placed.W); + } + + [Fact] + public async Task ResizeRequested_WithoutUsableRect_DoesNotPlace() + { + FakeHost host = new FakeHost(); + FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), () => { }); + await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }); + + host.FireResize(new WidgetAction { IsActionEvent = true, HasResize = false }); + Assert.Null(host.Placed); + } + + [Fact] + public async Task Close_ReportsClosedAndClosesHost() + { + FakeHost host = new FakeHost(); + FakeFeedback fb = new FakeFeedback(); + bool closedCb = false; + FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, fb, () => closedCb = true); + await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }); + + host.FireNav("https://countly_action_event/?cly_widget_command=1&close=1"); + + Assert.True(fb.ReportedClose); + Assert.True(host.Closed); + Assert.True(closedCb); + } + + [Fact] + public async Task NullUrl_TearsDownWithoutNavigating() + { + FakeHost host = new FakeHost(); + bool closedCb = false; + FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback { NullUrl = true }, () => closedCb = true); + + await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }); + + Assert.Null(host.NavigatedUrl); // never navigated to a null URL + Assert.True(host.Closed); + Assert.True(closedCb); + } + + [Fact] + public async Task LoadFailed_ClosesHostAndFiresOnClosed() + { + FakeHost host = new FakeHost(); + bool closedCb = false; + FeedbackWidgetPresenter p = new FeedbackWidgetPresenter(host, new FakeFeedback(), () => closedCb = true); + await p.StartAsync(new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }); + + host.FireLoadFailed(); + + Assert.True(host.Closed); + Assert.True(closedCb); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs b/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs new file mode 100644 index 00000000..7a5d2769 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/LocalMockServer.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; + +namespace Countly.UI.WebView2.Tests +{ + // Minimal self-contained HTTP mock (no dependency on the SDK's TestProject_common helpers, + // which reach SDK internals not visible from this external assembly). Records requests and + // returns JSON from an optional responder (rawUrl, body) => json. + internal sealed class LocalMockServer : IDisposable + { + private readonly HttpListener _listener; + private readonly Func _responder; + + public string Url { get; } + public List Requests { get; } = new List(); + + public LocalMockServer(Func responder = null) + { + _responder = responder; + int port = FreePort(); + Url = $"http://localhost:{port}/"; + _listener = new HttpListener(); + _listener.Prefixes.Add(Url); + _listener.Start(); + Thread t = new Thread(Loop) { IsBackground = true }; + t.Start(); + } + + private void Loop() + { + while (_listener.IsListening) { + try { + HttpListenerContext ctx = _listener.GetContext(); + string body; + using (StreamReader r = new StreamReader(ctx.Request.InputStream)) { body = r.ReadToEnd(); } + lock (Requests) { Requests.Add(new Captured { RawUrl = ctx.Request.RawUrl, Method = ctx.Request.HttpMethod, Body = body }); } + + string json = _responder?.Invoke(ctx.Request.RawUrl, body) ?? "{\"result\":\"success\"}"; + byte[] buf = Encoding.UTF8.GetBytes(json); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = buf.Length; + ctx.Response.OutputStream.Write(buf, 0, buf.Length); + ctx.Response.Close(); + } catch { /* listener stopped */ } + } + } + + private static int FreePort() + { + TcpListener l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + int p = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return p; + } + + public void Dispose() { try { _listener.Stop(); } catch { /* listener already stopped/disposed; nothing to do */ } } + + public sealed class Captured + { + public string RawUrl; + public string Method; + public string Body; + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs new file mode 100644 index 00000000..f65eda06 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ModuleContentAccessorTests.cs @@ -0,0 +1,50 @@ +using CountlySDK.Entities; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + // Runtime verification of the Content() accessor wiring. 'Countly' is fully qualified because + // the test's 'Countly.UI.WebView2.Tests' namespace shadows the CountlySDK.Countly class. + public class ModuleContentAccessorTests + { + [Fact] + public void Content_ReturnsMock_WhenConsentRequiredAndNotGiven() + { + CountlySDK.Countly.Halt(); + CountlyConfig cc = new CountlyConfig { + serverUrl = "https://no-server.count.ly", + appKey = "APP_KEY", + appVersion = "1.0", + consentRequired = true + }; + CountlySDK.Countly.Instance.Init(cc).Wait(); + + // No Content consent -> MockContent -> operations are safe no-ops. + CountlySDK.Countly.Instance.Content().EnterContentZone(); + CountlySDK.Countly.Instance.Content().ExitContentZone(); + CountlySDK.Countly.Instance.Content().RefreshContentZone(); + CountlySDK.Countly.Instance.Content().PreviewContent("cid"); + + CountlySDK.Countly.Halt(); + } + + [Fact] + public void Accessors_WhenNotInitialized_ReturnMocksWithoutThrowing() + { + // Repro of the reported crash: calling an accessor before Init (Configuration == null) + // NRE'd at CountlyBase.Content() 'if (Configuration.backendMode)'. Configuration is + // internal and never nulled by Halt, so force the not-initialized state via reflection. + CountlySDK.Countly.Halt(); + System.Reflection.FieldInfo cfg = typeof(CountlySDK.CountlyCommon.CountlyBase).GetField( + "Configuration", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(cfg); + cfg.SetValue(CountlySDK.Countly.Instance, null); + + // Each accessor must return a no-op mock, not throw NullReferenceException. + Assert.NotNull(CountlySDK.Countly.Instance.Content()); + CountlySDK.Countly.Instance.Content().EnterContentZone(); + Assert.NotNull(CountlySDK.Countly.Instance.Feedback()); + Assert.NotNull(CountlySDK.Countly.Instance.RemoteConfig()); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs new file mode 100644 index 00000000..21f20cca --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ModuleContentZoneTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading; +using CountlySDK.CountlyCommon; +using CountlySDK.Entities; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + internal sealed class FakeContentDisplay : IContentDisplay + { + public ContentScreen Screen = new ContentScreen { Width = 1000, Height = 800 }; + public volatile string PresentedUrl; + public volatile ContentPlacement PresentedPortrait; + public Action LastOnClosed; + + public ContentScreen GetScreen() { return Screen; } + public void Present(ContentPlacement portrait, ContentPlacement landscape, string url, Action onClosed) + { + PresentedPortrait = portrait; + PresentedUrl = url; + LastOnClosed = onClosed; + } + } + + public class ModuleContentZoneTests + { + [Fact] + public void EnterContentZone_FetchesAndPresents() + { + using LocalMockServer server = new LocalMockServer((rawUrl, body) => + (rawUrl + body).Contains("method=queue") + ? "{\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":320,\"h\":480},\"l\":{\"x\":0,\"y\":0,\"w\":800,\"h\":600}},\"html\":\"https://s/content/1\"}" + : null); + + CountlySDK.Countly.Halt(); + CountlyConfig cc = new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }; + cc.ContentZoneTimerInterval = 16; + CountlySDK.Countly.Instance.Init(cc).Wait(); + + FakeContentDisplay display = new FakeContentDisplay(); + CountlySDK.Countly.Instance.SetContentDisplay(display); + CountlySDK.Countly.Instance.Content().EnterContentZone(); + + for (int i = 0; i < 120 && display.PresentedUrl == null; i++) { Thread.Sleep(100); } + + Assert.Equal("https://s/content/1", display.PresentedUrl); + Assert.Equal(320, display.PresentedPortrait.W); + + CountlySDK.Countly.Instance.Content().ExitContentZone(); + CountlySDK.Countly.Halt(); + } + + [Fact] + public void PreviewContent_OneShotFetchesAndPresents() + { + using LocalMockServer server = new LocalMockServer((rawUrl, body) => + (rawUrl + body).Contains("preview=true") + ? "{\"geo\":{\"p\":{\"x\":0,\"y\":0,\"w\":300,\"h\":300},\"l\":{\"x\":0,\"y\":0,\"w\":300,\"h\":300}},\"html\":\"https://s/content/preview\"}" + : null); + + CountlySDK.Countly.Halt(); + CountlyConfig cc = new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }; + CountlySDK.Countly.Instance.Init(cc).Wait(); + + FakeContentDisplay display = new FakeContentDisplay(); + CountlySDK.Countly.Instance.SetContentDisplay(display); + CountlySDK.Countly.Instance.Content().PreviewContent("cid1"); + + for (int i = 0; i < 50 && display.PresentedUrl == null; i++) { Thread.Sleep(100); } + Assert.Equal("https://s/content/preview", display.PresentedUrl); + CountlySDK.Countly.Halt(); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs new file mode 100644 index 00000000..83218f18 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackAccessorTests.cs @@ -0,0 +1,34 @@ +using CountlySDK.CountlyCommon; +using CountlySDK.Entities; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + // Runtime verification of the Feedback() accessor wiring (T2). Uses only PUBLIC API, so it + // works from this external harness (no SDK internals needed). Confirms Countly.Init runs + // under the net8.0-windows host and that consent gating returns the no-op MockFeedback. + // Note: 'Countly' must be fully qualified as CountlySDK.Countly here, because the test's own + // 'Countly.UI.WebView2.Tests' namespace shadows the CountlySDK.Countly class name. + public class ModuleFeedbackAccessorTests + { + [Fact] + public void Feedback_ReturnsMock_WhenConsentRequiredAndNotGiven() + { + CountlySDK.Countly.Halt(); + CountlyConfig cc = new CountlyConfig { + serverUrl = "https://no-server.count.ly", + appKey = "APP_KEY", + appVersion = "1.0", + consentRequired = true + }; + CountlySDK.Countly.Instance.Init(cc).Wait(); + + // Consent for Feedback not granted -> accessor returns MockFeedback -> empty, never null, no throw. + CountlyFeedbackWidget[] widgets = CountlySDK.Countly.Instance.Feedback().GetAvailableFeedbackWidgets().Result; + + Assert.NotNull(widgets); + Assert.Empty(widgets); + CountlySDK.Countly.Halt(); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs new file mode 100644 index 00000000..120e9cf2 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/ModuleFeedbackNetworkTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using CountlySDK.CountlyCommon; +using CountlySDK.Entities; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + // Local integration tests for the feedback network layer (T3/T4/T5). Uses only PUBLIC SDK + // API + a local mock server, so they run from this external harness. (The event-queue + // assertion for T5 that inspects Countly.Instance.Events directly lives in the source-linked + // TestProject_common test for the VS build; here we verify T5 via the observable /i upload.) + public class ModuleFeedbackNetworkTests + { + [Fact] + public void GetAvailableFeedbackWidgets_FetchesAndParses() + { + using LocalMockServer server = new LocalMockServer((rawUrl, body) => + body != null && body.Contains("method=feedback") + ? "{\"result\":[{\"_id\":\"w1\",\"type\":\"nps\",\"name\":\"N\",\"tg\":[]}]}" + : null); + + CountlySDK.Countly.Halt(); + CountlySDK.Countly.Instance.Init(new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }).Wait(); + + CountlyFeedbackWidget[] widgets = CountlySDK.Countly.Instance.Feedback().GetAvailableFeedbackWidgets().Result; + + Assert.Single(widgets); + Assert.Equal("w1", widgets[0].widgetId); + Assert.Equal(FeedbackWidgetType.nps, widgets[0].type); + CountlySDK.Countly.Halt(); + } + + [Fact] + public void GetFeedbackWidgetData_HitsSurveyEndpointAndParses() + { + using LocalMockServer server = new LocalMockServer((rawUrl, body) => + rawUrl.Contains("/o/surveys/") ? "{\"name\":\"My NPS\"}" : null); + + CountlySDK.Countly.Halt(); + CountlySDK.Countly.Instance.Init(new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }).Wait(); + + CountlyFeedbackWidget widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }; + JObject data = CountlySDK.Countly.Instance.Feedback().GetFeedbackWidgetData(widget).Result; + + Assert.NotNull(data); + Assert.Equal("My NPS", (string)data["name"]); + + LocalMockServer.Captured req = server.Requests.Last(r => r.RawUrl.Contains("/o/surveys/")); + Assert.Contains("/o/surveys/nps/widget", req.RawUrl); + Assert.Contains("widget_id=w1", req.RawUrl + req.Body); + Assert.Contains("shown=1", req.RawUrl + req.Body); + CountlySDK.Countly.Halt(); + } + + [Fact] + public void ReportFeedbackWidgetManually_UploadsNpsEventWithWidgetSegmentation() + { + using LocalMockServer server = new LocalMockServer((rawUrl, body) => "{\"result\":\"success\"}"); + + CountlySDK.Countly.Halt(); + CountlySDK.Countly.Instance.Init(new CountlyConfig { serverUrl = server.Url, appKey = "APP_KEY", appVersion = "1.0" }).Wait(); + + CountlyFeedbackWidget widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }; + Dictionary result = new Dictionary { { "rating", 9 } }; + CountlySDK.Countly.Instance.Feedback().ReportFeedbackWidgetManually(widget, null, result).Wait(); + + // The [CLY]_nps event is uploaded to /i; its (url-encoded) body must carry the widget segmentation. + bool found = server.Requests.Any(r => { + string decoded = System.Uri.UnescapeDataString(r.Body ?? ""); + return decoded.Contains("[CLY]_nps") && decoded.Contains("widget_id") && decoded.Contains("w1"); + }); + Assert.True(found, "No uploaded request carried the NPS feedback event. Captured bodies: " + + string.Join(" || ", server.Requests.Select(r => r.Body))); + CountlySDK.Countly.Halt(); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs b/ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs new file mode 100644 index 00000000..e305835b --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/WebView2AndEntryPointTests.cs @@ -0,0 +1,39 @@ +using System; +using CountlySDK.CountlyCommon; +using CountlySDK.UI; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + // T10-T12 verification that does NOT require a UI thread or the WebView2 runtime: + // the runtime probe is tolerant, and the entry points are asserted to exist by reflection + // (the real WebView2 display path is exercised via the sample apps). + public class WebView2AndEntryPointTests + { + [Fact] + public void WebView2Runtime_IsAvailable_DoesNotThrow() + { + bool available = WebView2Runtime.IsAvailable(out string version); + // On CI without the runtime this is false + null; on a dev machine it may be true. + Assert.True(available || version == null); + } + + [Fact] + public void PresentFeedbackWidget_Wpf_SignatureExists() + { + System.Reflection.MethodInfo m = typeof(CountlyWebView).GetMethod( + "PresentFeedbackWidget", + new[] { typeof(System.Windows.Window), typeof(CountlyFeedbackWidget), typeof(Action) }); + Assert.NotNull(m); + } + + [Fact] + public void PresentFeedbackWidget_WinForms_SignatureExists() + { + System.Reflection.MethodInfo m = typeof(CountlyWebView).GetMethod( + "PresentFeedbackWidget", + new[] { typeof(System.Windows.Forms.IWin32Window), typeof(CountlyFeedbackWidget), typeof(Action) }); + Assert.NotNull(m); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs new file mode 100644 index 00000000..9d5bce6b --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/WidgetActionParserTests.cs @@ -0,0 +1,30 @@ +using System; +using CountlySDK.CountlyCommon; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class WidgetActionParserTests + { + [Fact] + public void ParsesResizeAndClose() + { + string resizeUrl = "https://countly_action_event?cly_widget_command=1&action=resize_me&resize_me=" + + Uri.EscapeDataString("{\"p\":{\"x\":1,\"y\":2,\"w\":300,\"h\":400},\"l\":{\"x\":5,\"y\":6,\"w\":700,\"h\":500}}"); + WidgetAction a = WidgetActionParser.Parse(resizeUrl); + Assert.True(a.IsActionEvent); + Assert.True(a.HasResize); + Assert.Equal(300, a.Portrait.W); + Assert.Equal(400, a.Portrait.H); + Assert.Equal(700, a.Landscape.W); + Assert.False(a.Close); + + WidgetAction c = WidgetActionParser.Parse("https://countly_action_event?cly_widget_command=1&close=1"); + Assert.True(c.IsActionEvent); + Assert.True(c.Close); + + WidgetAction n = WidgetActionParser.Parse("https://example.com/feedback/nps?widget_id=w1"); + Assert.False(n.IsActionEvent); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/WidgetMessageParserTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetMessageParserTests.cs new file mode 100644 index 00000000..b1928269 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/WidgetMessageParserTests.cs @@ -0,0 +1,43 @@ +using CountlySDK.CountlyCommon; +using CountlySDK.UI; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class WidgetMessageParserTests + { + [Fact] + public void ParsesResizeMe_BothOrientations() + { + string json = "{\"cly_widget_command\":1,\"action\":\"resize_me\",\"resize_me\":{\"p\":{\"x\":10,\"y\":20,\"w\":300,\"h\":400},\"l\":{\"x\":5,\"y\":6,\"w\":700,\"h\":350}}}"; + Assert.True(WidgetMessageParser.TryParse(json, out WidgetAction a)); + Assert.True(a.HasResize); + Assert.Equal(300, a.Portrait.W); + Assert.Equal(20, a.Portrait.Y); + Assert.Equal(700, a.Landscape.W); + } + + [Fact] + public void ResizeMe_WithoutWidthHeight_IsNotAResize() + { + string json = "{\"cly_widget_command\":1,\"action\":\"resize_me\",\"resize_me\":{\"p\":{\"x\":0,\"y\":0},\"l\":{\"x\":0,\"y\":0}}}"; + Assert.True(WidgetMessageParser.TryParse(json, out WidgetAction a)); + Assert.False(a.HasResize); + } + + [Fact] + public void ParsesClose() + { + string json = "{\"cly_widget_command\":1,\"close\":1}"; + Assert.True(WidgetMessageParser.TryParse(json, out WidgetAction a)); + Assert.True(a.Close); + } + + [Fact] + public void NonWidgetJson_ReturnsFalse() + { + Assert.False(WidgetMessageParser.TryParse("{\"type\":\"resize\",\"width\":400}", out _)); + Assert.False(WidgetMessageParser.TryParse("not json", out _)); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/WidgetPlacementTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetPlacementTests.cs new file mode 100644 index 00000000..2e412620 --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/WidgetPlacementTests.cs @@ -0,0 +1,51 @@ +using CountlySDK.CountlyCommon; +using CountlySDK.UI; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class WidgetPlacementTests + { + private static WidgetAction Resize(int px,int py,int pw,int ph,int lx,int ly,int lw,int lh) => new WidgetAction { + IsActionEvent = true, HasResize = true, + Portrait = new WidgetRect { X=px,Y=py,W=pw,H=ph }, + Landscape = new WidgetRect { X=lx,Y=ly,W=lw,H=lh }, + }; + + [Fact] + public void LandscapeSurface_PicksLandscape_AndOffsetsByOrigin() + { + var surface = new WidgetSurface { X = 100, Y = 200, Width = 1920, Height = 1080 }; + var rect = WidgetPlacement.Resolve(Resize(0,0,300,400, 1420,460,500,620), surface); + Assert.NotNull(rect); + Assert.Equal(100 + 1420, rect.X); + Assert.Equal(200 + 460, rect.Y); + Assert.Equal(500, rect.W); + Assert.Equal(620, rect.H); + } + + [Fact] + public void PortraitSurface_PicksPortrait() + { + var surface = new WidgetSurface { X = 0, Y = 0, Width = 800, Height = 1200 }; + var rect = WidgetPlacement.Resolve(Resize(10,20,300,400, 0,0,700,350), surface); + Assert.Equal(300, rect.W); + Assert.Equal(10, rect.X); + } + + [Fact] + public void ClampsWithinSurface() + { + var surface = new WidgetSurface { X = 0, Y = 0, Width = 500, Height = 500 }; + var rect = WidgetPlacement.Resolve(Resize(400,400,300,300, 400,400,300,300), surface); + Assert.True(rect.X + rect.W <= 500); + Assert.True(rect.Y + rect.H <= 500); + } + + [Fact] + public void NoUsableResize_ReturnsNull() + { + Assert.Null(WidgetPlacement.Resolve(new WidgetAction { IsActionEvent = true, HasResize = false }, new WidgetSurface { Width = 100, Height = 100 })); + } + } +} diff --git a/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs b/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs new file mode 100644 index 00000000..3db7cbde --- /dev/null +++ b/ui/Countly.UI.WebView2.Tests/WidgetUrlBuilderTests.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using CountlySDK.CountlyCommon; +using CountlySDK.CountlyCommon.Helpers; +using Xunit; + +namespace Countly.UI.WebView2.Tests +{ + public class WidgetUrlBuilderTests + { + private static Dictionary Base() => new Dictionary { + { "app_key", "APPKEY" }, { "device_id", "DEV1" }, + { "sdk_name", "csharp" }, { "sdk_version", "9.9.9" }, { "av", "1.2.3" } + }; + + [Fact] + public void BuildFeedbackWidgetUrl_ContainsRequiredParamsAndCustom() + { + var widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.survey }; + + string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly", widget, Base()); + + 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("%22tc%22%3A1", url); // "tc":1 + Assert.Contains("%22xb%22%3A1", url); // "xb":1 + } + + [Fact] + public void Custom_DropsRw() + { + var widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.survey }; + string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly/", widget, Base()); + Assert.Contains("custom=" + System.Uri.EscapeDataString("{\"tc\":1,\"xb\":1}"), url); + Assert.DoesNotContain("%22rw%22", url); // no "rw" key + } + + [Fact] + public void AppendsUnencodedOrigin_SchemeAndAuthorityOnly() + { + var widget = new CountlyFeedbackWidget { widgetId = "w1", type = FeedbackWidgetType.nps }; + string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly/path/", widget, Base()); + Assert.EndsWith("&origin=https://s.count.ly", url); + } + + [Fact] + public void BuildFeedbackWidgetUrl_NullWidgetId_DoesNotThrow() + { + var widget = new CountlyFeedbackWidget { widgetId = null, type = FeedbackWidgetType.nps }; + + // Must not throw ArgumentNullException from Uri.EscapeDataString(null). + string url = WidgetUrlBuilder.BuildFeedbackWidgetUrl("https://s.count.ly", widget, Base()); + + Assert.StartsWith("https://s.count.ly/feedback/nps?", url); + Assert.Contains("widget_id=", url); + } + } +} diff --git a/ui/Countly.UI.WebView2/CHANGELOG.md b/ui/Countly.UI.WebView2/CHANGELOG.md new file mode 100644 index 00000000..538a4b4e --- /dev/null +++ b/ui/Countly.UI.WebView2/CHANGELOG.md @@ -0,0 +1,10 @@ +## XX.XX.XX +* Initial release. +* Added "CountlyWebView.PresentFeedbackWidget(...)" for displaying Countly feedback widgets (Surveys, NPS, Ratings) in a WebView2 host: + * WPF overload: "PresentFeedbackWidget(System.Windows.Window owner, CountlyFeedbackWidget widget, Action onClosed = null)" + * WinForms overload: "PresentFeedbackWidget(System.Windows.Forms.IWin32Window owner, CountlyFeedbackWidget widget, Action onClosed = null)" + * Dynamically resizes the host window to the widget's requested size and auto-reports the result when the widget is closed. +* Added "CountlyWebView.EnableContentZone()" / "DisableContentZone()" for displaying Countly content (experimental) as a borderless, top-most WebView2 overlay positioned by the server on the primary screen (WPF). +* Added "WebView2Runtime.IsAvailable(out string version)" to detect the WebView2 Evergreen Runtime; when the runtime is missing, presentation is a graceful no-op. +* Targets .NET Framework 4.6.2 and .NET 8 (Windows). Depends on the "Countly" SDK package and "Microsoft.Web.WebView2". +* Requires the WebView2 Evergreen Runtime to be installed on the end-user machine. diff --git a/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj b/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj new file mode 100644 index 00000000..4115cdb8 --- /dev/null +++ b/ui/Countly.UI.WebView2/Countly.UI.WebView2.csproj @@ -0,0 +1,58 @@ + + + net462;net8.0-windows + true + true + CountlySDK.UI + Countly.UI.WebView2 + latest + + + true + ..\..\netstd\Countly\CountlyWinSDKStrongNameKey.pfx + false + + + true + + + Countly.UI.WebView2 + 26.1.0 + 26.1.0.0 + 26.1.0.0 + Countly Feedback Widgets and Content UI (WebView2) + Countly + Countly + Countly SDK + WebView2-based WPF/WinForms UI for the Countly Windows SDK: displays Feedback Widgets (Surveys, NPS, Ratings) and the Content feature. Companion to the Countly core package. Requires the Microsoft Edge WebView2 Evergreen runtime on the end-user machine. + (C) Countly + countly;analytics;feedback;surveys;nps;rating;content;webview2;wpf;winforms + https://github.com/Countly/countly-sdk-windows + https://github.com/Countly/countly-sdk-windows + git + MIT + icon.png + README.md + + + + + + + + + + + + + + + + + + + + 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.Dpi.cs b/ui/Countly.UI.WebView2/CountlyWebView.Dpi.cs new file mode 100644 index 00000000..7da8af7e --- /dev/null +++ b/ui/Countly.UI.WebView2/CountlyWebView.Dpi.cs @@ -0,0 +1,62 @@ +using System; +using System.Runtime.InteropServices; + +namespace CountlySDK.UI +{ + public static partial class CountlyWebView + { + /// + /// When true, feedback widgets anchor within the host app window; otherwise (default) the + /// whole screen. The host app must be per-monitor-DPI-aware for correct sizing/placement. + /// + public static bool ShowWidgetsWithinApp { get; set; } + + private static readonly IntPtr PerMonitorAwareV2 = new IntPtr(-4); + + [DllImport("user32.dll")] + private static extern bool SetProcessDpiAwarenessContext(IntPtr value); + + // Best-effort; only takes effect if no window exists yet. Hosts should also declare + // per-monitor DPI awareness (manifest or a module initializer) — see docs. + internal static void EnsurePerMonitorDpiAware() + { + try { SetProcessDpiAwarenessContext(PerMonitorAwareV2); } catch { } + } + + [DllImport("user32.dll")] + private static extern IntPtr GetThreadDpiAwarenessContext(); + + [DllImport("user32.dll")] + private static extern int GetAwarenessFromDpiAwarenessContext(IntPtr context); + + [DllImport("shcore.dll")] + private static extern int GetScaleFactorForMonitor(IntPtr hMon, out int scale); + + [DllImport("user32.dll")] + private static extern IntPtr MonitorFromPoint(POINT pt, uint flags); + + [StructLayout(LayoutKind.Sequential)] + private struct POINT { public int X, Y; } + + private const uint MonitorDefaultToPrimary = 1; + + /// + /// Synchronous best-effort estimate of window-units-per-CSS-px for the primary monitor, + /// computed WITHOUT a live webview so the first content fetch (which can precede the async + /// probe) already sizes correctly. If the process is DPI-aware, WPF already scales window + /// units to the monitor, so the factor is 1.0; if DPI-unaware, WPF uses 96-DPI units and we + /// multiply by the physical monitor scale. Returns 1.0 when the APIs are unavailable; the + /// content display then refines this from the page's devicePixelRatio. + /// + internal static double EstimateMonitorScale() + { + try { + int awareness = GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext()); + if (awareness > 0) { return 1.0; } // 1 = system-aware, 2 = per-monitor-aware + IntPtr mon = MonitorFromPoint(new POINT { X = 0, Y = 0 }, MonitorDefaultToPrimary); + if (GetScaleFactorForMonitor(mon, out int pct) == 0 && pct > 0) { return pct / 100.0; } + } catch { /* fall through to 1.0 */ } + return 1.0; + } + } +} diff --git a/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs new file mode 100644 index 00000000..50394418 --- /dev/null +++ b/ui/Countly.UI.WebView2/CountlyWebView.WinForms.cs @@ -0,0 +1,100 @@ +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 as a transparent card form placed where the widget asks + /// (corner of the screen, or the app window if is set). + /// Must be called on the UI thread. No-op (logs + onClosed) if the WebView2 runtime is missing. + /// + public static void PresentFeedbackWidget(IWin32Window owner, CountlyFeedbackWidget widget, Action onClosed = null) + { + if (widget == null) { onClosed?.Invoke(); return; } + EnsurePerMonitorDpiAware(); + + if (!WebView2Runtime.IsAvailable(out _)) { + System.Diagnostics.Debug.WriteLine("[CountlyWebView] WebView2 runtime not available; cannot present widget"); + onClosed?.Invoke(); + return; + } + + WidgetSurface surface = ResolveSurfaceWinForms(owner); + + Form form = new Form { + FormBorderStyle = FormBorderStyle.None, + StartPosition = FormStartPosition.Manual, + ShowInTaskbar = false, + TopMost = true, + // 1x1 on the real target monitor (not off-screen) so devicePixelRatio is measured + // correctly at init. Moved/sized by the presenter. + Location = new Point(surface.X, surface.Y), + ClientSize = new Size(1, 1) + }; + // TransparencyKey lets the card's transparent margins show through. + form.BackColor = Color.Magenta; + form.TransparencyKey = Color.Magenta; + WebView2 webView = new WebView2 { Dock = DockStyle.Fill }; + form.Controls.Add(webView); + + WinFormsWebView2WidgetHost adapter = new WinFormsWebView2WidgetHost(webView, form, surface); + FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), onClosed); + + form.Load += async (_, __) => { + try { + await adapter.InitializeAsync(); + await presenter.StartAsync(widget); + } catch (Exception ex) { + System.Diagnostics.Debug.WriteLine("[CountlyWebView] feedback widget failed: " + ex); + try { form.Close(); } catch { /* best-effort teardown */ } + onClosed?.Invoke(); + } + }; + form.Show(owner); + } + + private static WidgetSurface ResolveSurfaceWinForms(IWin32Window owner) + { + Control ctl = owner as Control; + if (ShowWidgetsWithinApp && ctl != null) { + Form form = ctl.FindForm() ?? ctl as Form; + if (form != null) { + Point p = form.PointToScreen(Point.Empty); + // Control.DeviceDpi is net4.7+; on net462 (system-DPI only) read it from a device context. +#if NET462 + float s; + using (Graphics g = form.CreateGraphics()) { s = g.DpiX / 96f; } +#else + float s = form.DeviceDpi / 96f; +#endif + return new WidgetSurface { + X = (int)(p.X / s), Y = (int)(p.Y / s), + Width = (int)(form.ClientSize.Width / s), Height = (int)(form.ClientSize.Height / s) + }; + } + } + // Screen.WorkingArea is physical px on a DPI-aware host; convert to DIPs (the WidgetSurface + // contract) like the within-app branch and the WPF equivalent. Uses the owner's DPI when + // available; falls back to 1.0 (no host to measure) which is correct on DPI-unaware hosts. + float scale = 1f; + if (ctl != null) { +#if NET462 + using (Graphics g = ctl.CreateGraphics()) { scale = g.DpiX / 96f; } +#else + scale = ctl.DeviceDpi / 96f; +#endif + } + if (scale <= 0) { scale = 1f; } + System.Drawing.Rectangle wa = Screen.PrimaryScreen.WorkingArea; + return new WidgetSurface { + X = (int)(wa.X / scale), Y = (int)(wa.Y / scale), + Width = (int)(wa.Width / scale), Height = (int)(wa.Height / scale) + }; + } + } +} diff --git a/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs new file mode 100644 index 00000000..85af6b86 --- /dev/null +++ b/ui/Countly.UI.WebView2/CountlyWebView.Wpf.cs @@ -0,0 +1,87 @@ +using System; +using System.Windows; +using System.Windows.Media; +using CountlySDK.CountlyCommon; +using Microsoft.Web.WebView2.Wpf; + +namespace CountlySDK.UI +{ + public static partial class CountlyWebView + { + /// + /// Presents a feedback widget as a transparent, click-through-surrounded card window placed + /// where the widget asks (corner of the screen, or the app window if + /// is set). Must be called on the UI thread. No-op (logs + + /// onClosed) if the WebView2 runtime is missing. + /// + public static void PresentFeedbackWidget(Window owner, CountlyFeedbackWidget widget, Action onClosed = null) + { + if (widget == null) { onClosed?.Invoke(); return; } + EnsurePerMonitorDpiAware(); + + if (!WebView2Runtime.IsAvailable(out _)) { + System.Diagnostics.Debug.WriteLine("[CountlyWebView] WebView2 runtime not available; cannot present widget"); + onClosed?.Invoke(); + return; + } + + WidgetSurface surface = ResolveSurface(owner); + + // Transparent card window; starts tiny + off-screen so WebView2 can initialize, then the + // presenter moves/sizes it to the widget's requested rect. + // Opaque borderless window: a transparent (AllowsTransparency) window hit-tests by WPF's + // own visual alpha, which treats the WebView2 region as transparent -> clicks fall + // through the card. Opaque keeps the card interactive; clicks outside simply aren't ours. + Window host = new Window { + WindowStyle = WindowStyle.None, + ResizeMode = ResizeMode.NoResize, + ShowInTaskbar = false, + Topmost = true, + // Owner couples the card to the host app's lifecycle/z-order (minimizes and closes + // with it, never orphaned on top of other apps), matching the WinForms Show(owner) path. + Owner = owner, + WindowStartupLocation = WindowStartupLocation.Manual, + // Start as a 1x1 window on the real target monitor (not off-screen) so the + // devicePixelRatio measured at init reflects the correct monitor. Moved by PlaceAndShow. + Left = surface.X, Top = surface.Y, Width = 1, Height = 1 + }; + WebView2 webView = new WebView2(); + host.Content = webView; + + WebView2WidgetHost adapter = new WebView2WidgetHost(webView, host, surface); + FeedbackWidgetPresenter presenter = new FeedbackWidgetPresenter(adapter, CountlySDK.Countly.Instance.Feedback(), onClosed); + + host.Loaded += async (_, __) => { + try { + await adapter.InitializeAsync(); + await presenter.StartAsync(widget); + } catch (Exception ex) { + System.Diagnostics.Debug.WriteLine("[CountlyWebView] feedback widget failed: " + ex); + try { host.Close(); } catch { /* best-effort teardown */ } + onClosed?.Invoke(); + } + }; + host.Show(); + } + + private static WidgetSurface ResolveSurface(Window owner) + { + if (ShowWidgetsWithinApp && owner != null) { + Point tl = owner.PointToScreen(new Point(0, 0)); + DpiScale dpi = VisualTreeHelper.GetDpi(owner); + return new WidgetSurface { + X = (int)(tl.X / dpi.DpiScaleX), + Y = (int)(tl.Y / dpi.DpiScaleY), + Width = (int)owner.ActualWidth, + Height = (int)owner.ActualHeight + }; + } + return new WidgetSurface { + X = (int)SystemParameters.WorkArea.Left, + Y = (int)SystemParameters.WorkArea.Top, + Width = (int)SystemParameters.WorkArea.Width, + Height = (int)SystemParameters.WorkArea.Height + }; + } + } +} 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..ecc7af4d --- /dev/null +++ b/ui/Countly.UI.WebView2/FeedbackWidgetPresenter.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; +using CountlySDK.CountlyCommon; + +namespace CountlySDK.UI +{ + /// + /// Drives a feedback widget: navigates the URL, reports the surface size once the page has + /// loaded (so the widget can compute its corner rect), places the card window at the widget's + /// requested rect, and handles close. No WebView2 types — unit-testable with a fake host. + /// + public class FeedbackWidgetPresenter + { + private readonly IWidgetWebHost _host; + private readonly Feedback _feedback; + private readonly Action _onClosed; + private CountlyFeedbackWidget _widget; + private bool _surfaceReported; + + /// Creates a presenter bound to a host, the core feedback module, and a dismissal callback. + public FeedbackWidgetPresenter(IWidgetWebHost host, Feedback feedback, Action onClosed) + { + _host = host; + _feedback = feedback; + _onClosed = onClosed; + _host.NavigationStarting += OnNavigationStarting; + _host.ResizeRequested += OnResizeRequested; + _host.PageLoaded += OnPageLoaded; + _host.LoadFailed += OnLoadFailed; + } + + // The widget URL failed to load; dismiss the (invisible) card and notify the caller so the + // presentation doesn't wedge with a stuck window and no onClosed callback. + private void OnLoadFailed() + { + _host.CloseHost(); + _onClosed?.Invoke(); + } + + /// 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); + // A null/empty URL means the widget is unavailable (e.g. Feedback consent not given, so + // the core returns MockFeedback). Tear down cleanly rather than navigating to null, + // which would throw and leave a 1x1 window flashing on screen. + if (string.IsNullOrEmpty(url)) { + _host.CloseHost(); + _onClosed?.Invoke(); + return; + } + _host.Navigate(url); + } + + // The widget only computes a correct rect once it knows the viewport, and it only accepts + // our resize message after its own listener is attached — i.e. after the page has loaded. + private void OnPageLoaded() + { + if (_surfaceReported) { return; } + _surfaceReported = true; + _host.ReportSurfaceSize(_host.Surface.Width, _host.Surface.Height); + // Placement happens when the widget posts resize_me (OnResizeRequested). Widgets that + // never post one (e.g. the rating template) are handled by the host's own fallback. + } + + private void OnResizeRequested(WidgetAction action) + { + WidgetRect rect = WidgetPlacement.Resolve(action, _host.Surface); + if (rect == null) { return; } + _host.PlaceAndShow(rect); + } + + private void OnNavigationStarting(string url) + { + WidgetAction action = WidgetActionParser.Parse(url); + if (action.IsActionEvent && action.Close) { + _ = _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..c1fb038e --- /dev/null +++ b/ui/Countly.UI.WebView2/IWidgetWebHost.cs @@ -0,0 +1,42 @@ +using System; +using CountlySDK.CountlyCommon; + +namespace CountlySDK.UI +{ + /// + /// Abstraction over the embedded browser that hosts a widget. Keeps the presentation logic in + /// testable without a real WebView2. The presenter reports + /// the surface size to the widget, then places the card window at the rect the widget requests + /// (its resize_me). The concrete adapters (WPF / WinForms) are thin. + /// + public interface IWidgetWebHost + { + /// Raised for each navigation the widget initiates; the argument is the target URI (used for close). + event Action NavigationStarting; + + /// Raised when the widget posts a usable resize_me (bridged from the page). + event Action ResizeRequested; + + /// Raised once the widget page has finished loading (safe to post the surface size). + event Action PageLoaded; + + /// Raised when the initial widget navigation fails (offline/404/error) before any + /// successful load, so the presenter can dismiss the otherwise-invisible card and fire onClosed. + event Action LoadFailed; + + /// The coordinate space handed to the widget (DIPs, screen-absolute origin). + WidgetSurface Surface { get; } + + /// Loads the given URL in the host. + void Navigate(string url); + + /// Tells the widget how much room it has by posting a {type:'resize',width,height} message. + void ReportSurfaceSize(int width, int height); + + /// Positions + sizes the card window to the widget's requested rect (DIPs) and shows it. + void PlaceAndShow(WidgetRect screenRect); + + /// Closes/dismisses the host window. + void CloseHost(); + } +} diff --git a/ui/Countly.UI.WebView2/README.md b/ui/Countly.UI.WebView2/README.md new file mode 100644 index 00000000..d9a90d1e --- /dev/null +++ b/ui/Countly.UI.WebView2/README.md @@ -0,0 +1,58 @@ +# Countly.UI.WebView2 + +WebView2-based WPF / WinForms UI for the [Countly Windows SDK](https://github.com/Countly/countly-sdk-windows). +It renders **Feedback Widgets** (Surveys, NPS, Ratings) and the **Content** feature on the desktop. +It is a thin companion to the core `Countly` package — the core drives analytics; this package only +displays. + +## Requirements + +- **Microsoft Edge WebView2 Evergreen Runtime** must be installed on the end-user machine. It ships + with recent Windows 10/11 and Microsoft Edge, but is not guaranteed on every machine — distribute + the [Evergreen Bootstrapper/Standalone installer](https://developer.microsoft.com/microsoft-edge/webview2/) + if you need to guarantee it. The SDK degrades gracefully (no-ops) when the runtime is absent. +- `Countly` core package (a matching version is a dependency of this package). +- .NET Framework 4.6.2+ or .NET 8 (`net462` / `net8.0-windows`), WPF or WinForms. + +## DPI awareness + +For crisp, correctly-placed widgets and content, make the host process **per-monitor DPI aware** +(an `app.manifest` `dpiAwareness` entry is the standard way). The SDK measures and compensates for +DPI at runtime, so it also works on DPI-unaware hosts, but per-monitor awareness is recommended. + +## Usage + +Initialize the core SDK as usual, then: + +### Feedback widgets + +```csharp +using CountlySDK.UI; + +// Fetch available widgets via the core, then present one: +var widgets = await Countly.Instance.Feedback().GetAvailableFeedbackWidgets(); + +// WPF (call on the UI thread; owner is your Window): +CountlyWebView.PresentFeedbackWidget(this, widgets[0], onClosed: () => { /* dismissed */ }); +``` + +The card sizes and positions itself where the widget asks (a screen corner by default, or within the +app window if `CountlyWebView.ShowWidgetsWithinApp = true`). + +### Content + +```csharp +using CountlySDK.UI; + +// Registers the display bridge and starts polling for content (experimental): +CountlyWebView.EnableContentZone(); +// ... +CountlyWebView.DisableContentZone(); +``` + +Content interactions (events, external links, resize, close) are handled automatically; emitted +events are recorded through the core and the queue is flushed so the server can react immediately. + +## License + +MIT — see the [LICENSE](https://github.com/Countly/countly-sdk-windows/blob/master/LICENSE). diff --git a/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs new file mode 100644 index 00000000..7c38ca7d --- /dev/null +++ b/ui/Countly.UI.WebView2/WebView2ContentDisplay.cs @@ -0,0 +1,186 @@ +using System; +using System.Globalization; +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 + /// work-area size and shows server-placed content in a borderless, top-most, non-modal WebView2 + /// window at the server-computed coordinates. Constructed on the UI thread (captures its + /// Dispatcher). + /// + /// Coordinate model: the server computes geo in the units of the resolution we report, + /// and the content page itself lays out in CSS px. So we report the work area in CSS px + /// (physical ÷ monitor-scale) and place the window at geo × monitor-scale for BOTH + /// position and size. On a DPI-aware host or a 100% monitor the scale is 1.0 and this is a + /// no-op; on a DPI-unaware host at 125% it makes a fullscreen item fill exactly and a top-right + /// item land on the screen edge (instead of scaling size only, which pushed content off-screen). + /// + internal sealed class WebView2ContentDisplay : IContentDisplay + { + private readonly Dispatcher _dispatcher; + + // window-units per CSS-px (= devicePixelRatio / host DPI scale). Static so it survives across + // content shows: GetScreen() runs during the fetch (before any content webview exists) and + // needs it, but it can only be *measured* from a live page. A one-time hidden-webview probe + // (started at construction, well before the first fetch) seeds it with the same signal the + // widget adapter uses; each content show then refines it from its own devicePixelRatio. + // Seeded synchronously (Win32) so the first fetch is correct even if it precedes the probe. + private static double _monitorScale = CountlyWebView.EstimateMonitorScale(); + private static bool _scaleProbeStarted; + + public WebView2ContentDisplay() + { + _dispatcher = Dispatcher.CurrentDispatcher; // captured on the UI thread + StartScaleProbe(); + } + + // Measure window-units-per-CSS-px once, before any content is fetched, from a throwaway 1×1 + // WebView2 on the primary work area — exactly how the widget adapter measures it (a Win32 DPI + // estimate is unreliable because it cannot see WPF's own DPI notion). On a DPI-aware host or a + // 100% monitor this is 1.0; on a DPI-unaware host at 125% it is 1.25. + private void StartScaleProbe() + { + if (_scaleProbeStarted) { return; } + _scaleProbeStarted = true; + _dispatcher.BeginInvoke(new Action(async () => { + Window probe = null; + try { + WebView2 wv = new WebView2(); + probe = new Window { + WindowStyle = WindowStyle.None, + ResizeMode = ResizeMode.NoResize, + ShowInTaskbar = false, + ShowActivated = false, + WindowStartupLocation = WindowStartupLocation.Manual, + Left = SystemParameters.WorkArea.Left, Top = SystemParameters.WorkArea.Top, + Width = 1, Height = 1, + Content = wv + }; + probe.Show(); // realize the HWND on the primary monitor so devicePixelRatio is accurate + await wv.EnsureCoreWebView2Async(null); + double wpfDpi = System.Windows.Media.VisualTreeHelper.GetDpi(probe).DpiScaleX; + string dprJson = await wv.CoreWebView2.ExecuteScriptAsync("window.devicePixelRatio"); + double.TryParse(dprJson, NumberStyles.Any, CultureInfo.InvariantCulture, out double dpr); + if (dpr > 0 && wpfDpi > 0) { _monitorScale = dpr / wpfDpi; } + } catch (Exception ex) { + // Non-fatal: fall back to 1.0 now and let the first content show refine the scale. + System.Diagnostics.Debug.WriteLine("[CountlyWebView] content scale probe failed: " + ex); + } finally { + try { if (probe != null) { probe.Close(); } } catch { } + } + })); + } + + public ContentScreen GetScreen() + { + // Report the work area in CSS px so the server's geo comes back in CSS px — the unit the + // content page lays out in. + double scale = _monitorScale > 0 ? _monitorScale : 1.0; + return new ContentScreen { + Width = (int)(SystemParameters.WorkArea.Width / scale), + Height = (int)(SystemParameters.WorkArea.Height / scale) + }; + } + + public void Present(ContentPlacement portrait, ContentPlacement landscape, string url, Action onClosed) + { + _dispatcher.BeginInvoke(new Action(() => { + try { + // Capture the scale used to report the resolution that produced this geo; place + // with the SAME scale (do not re-apply a freshly measured one to this window, or + // geo computed for the old resolution would be scaled twice). + double scale = _monitorScale > 0 ? _monitorScale : 1.0; + double originX = SystemParameters.WorkArea.Left; + double originY = SystemParameters.WorkArea.Top; + bool landscapeScreen = SystemParameters.WorkArea.Width >= SystemParameters.WorkArea.Height; + + ContentPlacement r = (landscapeScreen ? landscape : portrait) ?? portrait ?? landscape; + if (r == null) { onClosed?.Invoke(); return; } + + // Capture the content module NOW (consent is good — we were just asked to present) + // and relay events through it, rather than re-resolving Countly.Instance.Content() + // per navigation, which could return a no-op MockContent if consent is revoked or + // the SDK is re-initialised while this overlay is still on screen. + Content contentModule = CountlySDK.Countly.Instance.Content(); + + Window host = new Window { + WindowStyle = WindowStyle.None, + ResizeMode = ResizeMode.NoResize, + Topmost = true, + ShowInTaskbar = false, + ShowActivated = false, + WindowStartupLocation = WindowStartupLocation.Manual, + Left = originX + r.X * scale, Top = originY + r.Y * scale, + Width = r.W * scale, Height = r.H * scale + }; + WebView2 webView = new WebView2(); + host.Content = webView; + // WPF doesn't dispose child controls on close, so dispose the WebView2 explicitly + // to avoid leaking its browser host (msedgewebview2.exe) per content shown. + host.Closed += (_, __) => { try { webView.Dispose(); } catch { /* teardown best-effort */ } }; + + host.Loaded += async (_, e) => { + try { + await webView.EnsureCoreWebView2Async(null); + + // Refine the cached monitor scale from the live page for the NEXT fetch. + // Not re-applied to this window (see the capture note above). + try { + double wpfDpi = System.Windows.Media.VisualTreeHelper.GetDpi(host).DpiScaleX; + string dprJson = await webView.CoreWebView2.ExecuteScriptAsync("window.devicePixelRatio"); + double.TryParse(dprJson, NumberStyles.Any, CultureInfo.InvariantCulture, out double dpr); + if (dpr > 0 && wpfDpi > 0) { _monitorScale = dpr / wpfDpi; } + } catch { } + + webView.CoreWebView2.NavigationStarting += (_, e2) => { + WidgetAction a = WidgetActionParser.Parse(e2.Uri); + if (!a.IsActionEvent) { return; } + // Action-event URLs are signals, never real navigations. Cancel ALL of + // them, or the WebView navigates to the invalid countly_action_event host + // and the content is replaced by a chrome-error page. + e2.Cancel = true; + + // Process event/link first, close last (per the content protocol). + if (a.EventPayload != null) { + try { contentModule.RecordContentEvents(a.EventPayload); } + catch (Exception ev) { System.Diagnostics.Debug.WriteLine("[CountlyWebView] content event relay failed: " + ev); } + } + if (a.Link != null) { + // ShellExecute routes http(s) -> default browser and custom schemes -> deeplink handler. + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(a.Link) { UseShellExecute = true }); } + catch (Exception lx) { System.Diagnostics.Debug.WriteLine("[CountlyWebView] content link open failed: " + lx); } + } + if (a.HasResize) { + WidgetRect rect = landscapeScreen ? (a.Landscape ?? a.Portrait) : (a.Portrait ?? a.Landscape); + if (rect != null) { + host.Left = originX + rect.X * scale; host.Top = originY + rect.Y * scale; + host.Width = rect.W * scale; host.Height = rect.H * scale; + } + } + if (a.Close) { + host.Close(); + onClosed?.Invoke(); + } + }; + webView.CoreWebView2.Navigate(url); // 'html' from the server is a URL + } catch (Exception ex) { + // async void: a display failure must never crash the host app. + System.Diagnostics.Debug.WriteLine("[CountlyWebView] content overlay failed: " + ex); + try { host.Close(); } catch { /* best-effort close during teardown; not actionable */ } + onClosed?.Invoke(); // return the module to a fetchable state so the zone is not wedged + } + }; + host.Show(); + } catch (Exception ex) { + System.Diagnostics.Debug.WriteLine("[CountlyWebView] content overlay failed to open: " + ex); + onClosed?.Invoke(); + } + })); + } + } +} diff --git a/ui/Countly.UI.WebView2/WebView2Runtime.cs b/ui/Countly.UI.WebView2/WebView2Runtime.cs new file mode 100644 index 00000000..20cfb27e --- /dev/null +++ b/ui/Countly.UI.WebView2/WebView2Runtime.cs @@ -0,0 +1,23 @@ +using Microsoft.Web.WebView2.Core; + +namespace CountlySDK.UI +{ + /// Detects whether the WebView2 Evergreen Runtime is available on the machine. + public static class WebView2Runtime + { + /// + /// Returns true if the WebView2 runtime is installed. receives + /// the installed version string, or null if unavailable. Never throws. + /// + public static bool IsAvailable(out string version) + { + version = null; + try { + version = CoreWebView2Environment.GetAvailableBrowserVersionString(); + return !string.IsNullOrEmpty(version); + } catch { + return false; + } + } + } +} diff --git a/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs b/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs new file mode 100644 index 00000000..2b033f97 --- /dev/null +++ b/ui/Countly.UI.WebView2/WebView2WidgetHost.WinForms.cs @@ -0,0 +1,130 @@ +using System; +using System.Threading.Tasks; +using System.Windows.Forms; +using CountlySDK.CountlyCommon; +using Microsoft.Web.WebView2.WinForms; + +namespace CountlySDK.UI +{ + /// + /// Thin WinForms adapter mapping onto a WebView2 in a caller-owned + /// card . Mirrors the WPF adapter (scaled placement + measure-content fallback). + /// + internal sealed class WinFormsWebView2WidgetHost : IWidgetWebHost + { + public event Action NavigationStarting; + public event Action ResizeRequested; + public event Action PageLoaded; + public event Action LoadFailed; + public WidgetSurface Surface { get; } + + private readonly WebView2 _webView; + private readonly Form _form; + private double _scale = 1.0; + private bool _placed; + private bool _pageLoaded; + + public WinFormsWebView2WidgetHost(WebView2 webView, Form form, WidgetSurface surface) + { + _webView = webView; + _form = form; + Surface = surface; + } + + public async Task InitializeAsync() + { + await _webView.EnsureCoreWebView2Async(null); + _webView.DefaultBackgroundColor = System.Drawing.Color.White; + + try { + // Control.DeviceDpi is net4.7+; on net462 (system-DPI only) read it from a device context. +#if NET462 + double hostScale; + using (System.Drawing.Graphics g = _form.CreateGraphics()) { hostScale = g.DpiX / 96f; } +#else + double hostScale = _form.DeviceDpi / 96f; +#endif + string dprJson = await _webView.CoreWebView2.ExecuteScriptAsync("window.devicePixelRatio"); + double.TryParse(dprJson, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double dpr); + if (dpr > 0 && hostScale > 0) { _scale = dpr / hostScale; } + } catch { /* keep _scale = 1.0 */ } + + await _webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync( + "(function(){var w=window.chrome&&window.chrome.webview;if(!w)return;" + + "window.addEventListener('message',function(ev){try{var d=typeof ev.data==='string'?JSON.parse(ev.data):ev.data;" + + "if(d&&d.cly_widget_command){w.postMessage(JSON.stringify(d));}}catch(e){}});})();"); + + _webView.CoreWebView2.WebMessageReceived += (s, e) => { + string json; + try { json = e.TryGetWebMessageAsString(); } catch { return; } + if (WidgetMessageParser.TryParse(json, out WidgetAction a) && a.HasResize) { + ResizeRequested?.Invoke(a); + } + }; + + _webView.CoreWebView2.NavigationStarting += (s, e) => NavigationStarting?.Invoke(e.Uri); + _webView.CoreWebView2.NavigationCompleted += async (s, e) => { + if (!e.IsSuccess) { + // Initial widget navigation failed (offline/404/error) before any successful load: + // dismiss the invisible 1x1 card. Later failures (post-load) are ignored here. + if (!_pageLoaded) { LoadFailed?.Invoke(); } + return; + } + _pageLoaded = true; + PageLoaded?.Invoke(); + await Task.Delay(900); + if (!_placed) { PlaceByMeasuredContent(); } + }; + } + + public void Navigate(string url) { _webView.CoreWebView2.Navigate(url); } + + public void ReportSurfaceSize(int width, int height) + { + _ = _webView.CoreWebView2.ExecuteScriptAsync("window.postMessage({type:'resize',width:" + width + ",height:" + height + "},'*');"); + } + + public void PlaceAndShow(WidgetRect r) + { + _placed = true; + int w = (int)(r.W * _scale); + int h = (int)(r.H * _scale); + int localX = r.X - Surface.X; + int localY = r.Y - Surface.Y; + int x = Surface.X + Math.Max(0, Math.Min(localX, Math.Max(0, Surface.Width - w))); + int y = Surface.Y + Math.Max(0, Math.Min(localY, Math.Max(0, Surface.Height - h))); + _form.Location = new System.Drawing.Point(x, y); + _form.ClientSize = new System.Drawing.Size(w, h); + if (!_form.Visible) { _form.Show(); } + } + + private async void PlaceByMeasuredContent() + { + _placed = true; + try { + const int cssW = 400; + int w = (int)(cssW * _scale); + _form.Location = new System.Drawing.Point(Surface.X, Surface.Y); + _form.ClientSize = new System.Drawing.Size(w, Surface.Height); + if (!_form.Visible) { _form.Show(); } + await Task.Delay(400); + + string js = "(function(){var e=document.getElementById('widget-body');" + + "return Math.ceil(e?e.scrollHeight:document.body.scrollHeight);})()"; + int.TryParse(await _webView.CoreWebView2.ExecuteScriptAsync(js), + System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out int cssH); + if (cssH <= 0) { cssH = 500; } + int h = (int)(cssH * _scale); + if (h > Surface.Height) { h = Surface.Height; } + + int x = Surface.X + Math.Max(0, (Surface.Width - w) / 2); + int y = Surface.Y + Math.Max(0, (Surface.Height - h) / 2); + _form.Location = new System.Drawing.Point(x, y); + _form.ClientSize = new System.Drawing.Size(w, h); + } catch { /* best-effort fallback */ } + } + + 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..4bba0665 --- /dev/null +++ b/ui/Countly.UI.WebView2/WebView2WidgetHost.cs @@ -0,0 +1,150 @@ +using System; +using System.Threading.Tasks; +using System.Windows; +using CountlySDK.CountlyCommon; +using Microsoft.Web.WebView2.Wpf; + +namespace CountlySDK.UI +{ + /// + /// Thin WPF adapter mapping onto a WebView2 in an opaque, borderless + /// caller-owned card . Sizes the window to the widget's resize_me rect + /// (scaled by the measured window-units-per-CSS-px); if no resize_me arrives (legacy rating + /// template), it falls back to measuring the rendered content. + /// + internal sealed class WebView2WidgetHost : IWidgetWebHost + { + public event Action NavigationStarting; + public event Action ResizeRequested; + public event Action PageLoaded; + public event Action LoadFailed; + public WidgetSurface Surface { get; } + + private readonly WebView2 _webView; + private readonly Window _window; + // window-units per CSS-px (= devicePixelRatio / host DPI scale). 1.0 until measured at init. + private double _scale = 1.0; + private bool _placed; + private bool _pageLoaded; + + public WebView2WidgetHost(WebView2 webView, Window window, WidgetSurface surface) + { + _webView = webView; + _window = window; + Surface = surface; + // WPF (unlike WinForms) does not dispose child controls when a Window closes, so the + // WebView2's CoreWebView2 browser host would leak (an orphaned msedgewebview2.exe). + // Dispose it on close, covering every teardown path (CloseHost, load failure, user close). + _window.Closed += (_, __) => { try { _webView.Dispose(); } catch { /* teardown best-effort */ } }; + } + + public async Task InitializeAsync() + { + await _webView.EnsureCoreWebView2Async(null); + _webView.DefaultBackgroundColor = System.Drawing.Color.White; + + // Measure window-units-per-CSS-px = devicePixelRatio / host DPI scale. On a DPI-unaware + // host this is the monitor scale (e.g. 1.25); on a DPI-aware host it is 1.0. Read on the + // blank page (already on the target monitor) so it's ready before the first resize_me. + try { + double wpfDpi = System.Windows.Media.VisualTreeHelper.GetDpi(_window).DpiScaleX; + string dprJson = await _webView.CoreWebView2.ExecuteScriptAsync("window.devicePixelRatio"); + double.TryParse(dprJson, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double dpr); + if (dpr > 0 && wpfDpi > 0) { _scale = dpr / wpfDpi; } + } catch { /* keep _scale = 1.0 */ } + + // Bridge: the widget posts resize_me/close to window.parent; forward those to the host. + await _webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync( + "(function(){var w=window.chrome&&window.chrome.webview;if(!w)return;" + + "window.addEventListener('message',function(ev){try{var d=typeof ev.data==='string'?JSON.parse(ev.data):ev.data;" + + "if(d&&d.cly_widget_command){w.postMessage(JSON.stringify(d));}}catch(e){}});})();"); + + _webView.CoreWebView2.WebMessageReceived += (s, e) => { + string json; + try { json = e.TryGetWebMessageAsString(); } catch { return; } + if (WidgetMessageParser.TryParse(json, out WidgetAction a) && a.HasResize) { + ResizeRequested?.Invoke(a); + } + }; + + _webView.CoreWebView2.NavigationStarting += (s, e) => NavigationStarting?.Invoke(e.Uri); + _webView.CoreWebView2.NavigationCompleted += async (s, e) => { + if (!e.IsSuccess) { + // The very first navigation is the widget URL. If it fails before any successful + // load (offline/404/error page), the card would stay an invisible, undismissed + // 1x1 window — dismiss it. Later failures (e.g. the cancelled countly_action_event + // close navigation) happen after _pageLoaded and are ignored here. + if (!_pageLoaded) { LoadFailed?.Invoke(); } + return; + } + _pageLoaded = true; + PageLoaded?.Invoke(); + // Fallback: if no resize_me placed the widget shortly after load (e.g. the rating + // template, which never sends one), measure the rendered content and place it. + await Task.Delay(900); + if (!_placed) { PlaceByMeasuredContent(); } + }; + } + + public void Navigate(string url) { _webView.CoreWebView2.Navigate(url); } + + public void ReportSurfaceSize(int width, int height) + { + string script = "window.postMessage({type:'resize',width:" + width + ",height:" + height + "},'*');"; + _ = _webView.CoreWebView2.ExecuteScriptAsync(script); + } + + public void PlaceAndShow(WidgetRect r) + { + _placed = true; + // Scale the window by the measured window-units-per-CSS-px so the webview's CSS viewport + // equals the card's CSS size -> content fits. Re-clamp to stay on-screen. + int w = (int)(r.W * _scale); + int h = (int)(r.H * _scale); + int localX = r.X - Surface.X; + int localY = r.Y - Surface.Y; + int x = Surface.X + Math.Max(0, Math.Min(localX, Math.Max(0, Surface.Width - w))); + int y = Surface.Y + Math.Max(0, Math.Min(localY, Math.Max(0, Surface.Height - h))); + _window.Left = x; + _window.Top = y; + _window.Width = w; + _window.Height = h; + if (!_window.IsVisible) { _window.Show(); } + } + + // Fallback for widgets that send no resize_me: lay the content out at a default width, + // measure its height, then center-place the window sized to it. + private async void PlaceByMeasuredContent() + { + _placed = true; + try { + const int cssW = 400; + int w = (int)(cssW * _scale); + _window.Left = Surface.X; + _window.Top = Surface.Y; + _window.Width = w; + _window.Height = Surface.Height; + if (!_window.IsVisible) { _window.Show(); } + await Task.Delay(400); + + string js = "(function(){var e=document.getElementById('widget-body');" + + "return Math.ceil(e?e.scrollHeight:document.body.scrollHeight);})()"; + int.TryParse(await _webView.CoreWebView2.ExecuteScriptAsync(js), + System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out int cssH); + if (cssH <= 0) { cssH = 500; } + int h = (int)(cssH * _scale); + if (h > Surface.Height) { h = Surface.Height; } + + int x = Surface.X + Math.Max(0, (Surface.Width - w) / 2); + int y = Surface.Y + Math.Max(0, (Surface.Height - h) / 2); + _window.Left = x; + _window.Top = y; + _window.Width = w; + _window.Height = h; + } catch (Exception ex) { System.Diagnostics.Debug.WriteLine("[CountlyWebView] widget content-measure fallback failed: " + ex); } + } + + public void CloseHost() { _window.Close(); } + } +} diff --git a/ui/Countly.UI.WebView2/WidgetMessageParser.cs b/ui/Countly.UI.WebView2/WidgetMessageParser.cs new file mode 100644 index 00000000..44f80d58 --- /dev/null +++ b/ui/Countly.UI.WebView2/WidgetMessageParser.cs @@ -0,0 +1,74 @@ +using CountlySDK.CountlyCommon; +using Newtonsoft.Json.Linq; + +namespace CountlySDK.UI +{ + /// + /// Parses the widget's postMessage payloads (bridged to the host) — the + /// {cly_widget_command, action:'resize_me', resize_me:{p,l}} / {close} shape. + /// Distinct from the URL-based (which handles + /// countly_action_event navigations). A rect is only usable when it has positive w AND h. + /// Uses Newtonsoft (the SDK's JSON library) so it builds on net462 as well as net8.0. + /// + public static class WidgetMessageParser + { + /// + /// Parses a bridged widget postMessage payload into a . Returns + /// true if the JSON is a recognised cly_widget_command message (with + /// populated), false otherwise. + /// + public static bool TryParse(string json, out WidgetAction action) + { + action = new WidgetAction(); + if (string.IsNullOrEmpty(json)) { return false; } + + JToken token; + try { token = JToken.Parse(json); } + catch { return false; } + + if (!(token is JObject root)) { return false; } + if (root.Property("cly_widget_command") == null) { return false; } + + action.IsActionEvent = true; + + JToken close = root["close"]; + if (close != null && IsTruthy(close)) { + action.Close = true; + } + + if (root["resize_me"] is JObject rm) { + WidgetRect p = ReadRect(rm, "p"); + WidgetRect l = ReadRect(rm, "l"); + if (p != null || l != null) { + action.HasResize = true; + action.Portrait = p; + action.Landscape = l; + } + } + return true; + } + + private static WidgetRect ReadRect(JObject resizeMe, string key) + { + if (!(resizeMe[key] is JObject e)) { return null; } + int w = ReadInt(e, "w"); + int h = ReadInt(e, "h"); + if (w <= 0 || h <= 0) { return null; } + return new WidgetRect { X = ReadInt(e, "x"), Y = ReadInt(e, "y"), W = w, H = h }; + } + + private static int ReadInt(JObject obj, string name) + { + JToken v = obj[name]; + if (v != null && (v.Type == JTokenType.Integer || v.Type == JTokenType.Float)) { return (int)v; } + return 0; + } + + private static bool IsTruthy(JToken e) + { + if (e.Type == JTokenType.Boolean) { return (bool)e; } + if (e.Type == JTokenType.Integer || e.Type == JTokenType.Float) { return (double)e != 0; } + return false; + } + } +} diff --git a/ui/Countly.UI.WebView2/WidgetPlacement.cs b/ui/Countly.UI.WebView2/WidgetPlacement.cs new file mode 100644 index 00000000..592d5553 --- /dev/null +++ b/ui/Countly.UI.WebView2/WidgetPlacement.cs @@ -0,0 +1,46 @@ +using System; +using CountlySDK.CountlyCommon; + +namespace CountlySDK.UI +{ + /// The coordinate space we hand the widget (DIPs, screen-absolute origin). + public struct WidgetSurface + { + /// Left edge of the surface, screen-absolute (DIPs). + public int X; + /// Top edge of the surface, screen-absolute (DIPs). + public int Y; + /// Surface width (DIPs). + public int Width; + /// Surface height (DIPs). + public int Height; + } + + /// + /// Pure mapping from a widget resize request (CSS px relative to the surface) to an on-screen + /// window rect (DIPs, screen-absolute). CSS px == DIPs under per-monitor DPI awareness. + /// + public static class WidgetPlacement + { + /// + /// Resolves the widget's requested rect (orientation-appropriate, clamped to the surface) + /// into a screen-absolute window rect, or null if the action carries no usable rect. + /// + public static WidgetRect Resolve(WidgetAction action, WidgetSurface surface) + { + if (action == null || !action.HasResize) { return null; } + + bool landscape = surface.Width >= surface.Height; + WidgetRect r = landscape ? (action.Landscape ?? action.Portrait) : (action.Portrait ?? action.Landscape); + if (r == null) { return null; } + + int w = Math.Min(r.W, surface.Width); + int h = Math.Min(r.H, surface.Height); + int x = surface.X + Clamp(r.X, 0, surface.Width - w); + int y = surface.Y + Clamp(r.Y, 0, surface.Height - h); + return new WidgetRect { X = x, Y = y, W = w, H = h }; + } + + private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v); + } +} 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..1a1213ac --- /dev/null +++ b/ui/CountlyFeedbackDemo.Wpf/App.xaml.cs @@ -0,0 +1,28 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace CountlyFeedbackDemo.Wpf +{ + public partial class App : System.Windows.Application + { + } + + internal static class DpiBootstrap + { + // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (-4). Set at module load — before WPF + // initializes and locks the process DPI awareness — so WebView2 content renders 1:1 + // with the window instead of being mis-scaled/clipped. Windows 10 1703+. + private static readonly IntPtr PerMonitorAwareV2 = new IntPtr(-4); + + [DllImport("user32.dll")] + private static extern bool SetProcessDpiAwarenessContext(IntPtr value); + + [ModuleInitializer] + internal static void Init() + { + try { SetProcessDpiAwarenessContext(PerMonitorAwareV2); } + catch { /* pre-1703 Windows: fall back to the embedded manifest */ } + } + } +} diff --git a/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj b/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj new file mode 100644 index 00000000..b9544162 --- /dev/null +++ b/ui/CountlyFeedbackDemo.Wpf/CountlyFeedbackDemo.Wpf.csproj @@ -0,0 +1,15 @@ + + + WinExe + net8.0-windows + true + disable + false + false + app.manifest + + + + + + diff --git a/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml new file mode 100644 index 00000000..cdaadae1 --- /dev/null +++ b/ui/CountlyFeedbackDemo.Wpf/MainWindow.xaml @@ -0,0 +1,16 @@ + + + + + + +