Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 6 additions & 3 deletions countlyCommon/TestingRelated/ConsentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>());
Assert.True(consentObj.GetValue("users").ToObject<bool>());
Assert.False(consentObj.GetValue("views").ToObject<bool>());
Expand All @@ -206,6 +206,7 @@ public void TestConsentRequest()
Assert.False(consentObj.GetValue("feedback").ToObject<bool>());
Assert.False(consentObj.GetValue("star-rating").ToObject<bool>());
Assert.False(consentObj.GetValue("remote-config").ToObject<bool>());
Assert.False(consentObj.GetValue("content").ToObject<bool>());
}

/// <summary>
Expand Down Expand Up @@ -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<bool>());
Assert.True(consentObj.GetValue("users").ToObject<bool>());
Assert.False(consentObj.GetValue("views").ToObject<bool>());
Expand All @@ -252,6 +253,7 @@ public void TestGiveIndividualConsents()
Assert.False(consentObj.GetValue("feedback").ToObject<bool>());
Assert.False(consentObj.GetValue("star-rating").ToObject<bool>());
Assert.False(consentObj.GetValue("remote-config").ToObject<bool>());
Assert.False(consentObj.GetValue("content").ToObject<bool>());

Dictionary<ConsentFeatures, bool> consentToRemove = new Dictionary<ConsentFeatures, bool>();
consentToRemove.Add(ConsentFeatures.Crashes, false);
Expand All @@ -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<bool>());
Assert.True(consentObj.GetValue("users").ToObject<bool>());
Assert.False(consentObj.GetValue("views").ToObject<bool>());
Expand All @@ -276,6 +278,7 @@ public void TestGiveIndividualConsents()
Assert.False(consentObj.GetValue("feedback").ToObject<bool>());
Assert.False(consentObj.GetValue("star-rating").ToObject<bool>());
Assert.False(consentObj.GetValue("remote-config").ToObject<bool>());
Assert.False(consentObj.GetValue("content").ToObject<bool>());
}
}
}
3 changes: 2 additions & 1 deletion countlyCommon/TestingRelated/DeviceIdTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>());
Assert.True(consentObj.GetValue("users").ToObject<bool>());
Assert.False(consentObj.GetValue("views").ToObject<bool>());
Expand All @@ -152,6 +152,7 @@ public async void TestConsent_ChangeDeviceIdWithoutMerge()
Assert.False(consentObj.GetValue("feedback").ToObject<bool>());
Assert.False(consentObj.GetValue("star-rating").ToObject<bool>());
Assert.False(consentObj.GetValue("remote-config").ToObject<bool>());
Assert.False(consentObj.GetValue("content").ToObject<bool>());

type = collection.Get("t");
Assert.False(string.IsNullOrEmpty(type));
Expand Down
8 changes: 6 additions & 2 deletions countlyCommon/TestingRelated/TestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -528,12 +528,16 @@ internal static void ValidateRequestInQueue(string deviceId, string appKey, IDic
}
}

internal static void ValidateRequest(Dictionary<string, string> request, IDictionary<string, object> paramaters)
internal static void ValidateRequest(Dictionary<string, string> request, IDictionary<string, object> paramaters, IDictionary<string, Action<string, object>> customValidators = null)
{
ValidateBaseParams(request, DEVICE_ID, APP_KEY, 0);
Assert.Equal(11 + paramaters.Count, request.Count); // + rr
foreach (KeyValuePair<string, object> 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]);
}
}
}

Expand Down
8 changes: 7 additions & 1 deletion countlyCommon/TestingRelated/UserDetailsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Action<string, object>> {
{ "session_duration", (actual, _) => Assert.True(int.Parse(actual) >= 2 && int.Parse(actual) <= 4, "session_duration was " + actual) }
});

server.Dispose();
}
Expand Down
114 changes: 90 additions & 24 deletions countlyCommon/countlyCommon/CountlyBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
internal CountlyConfig Configuration;
internal ModuleBackendMode moduleBackendMode;
internal ModuleRemoteConfig moduleRemoteConfig;
internal ModuleFeedback moduleFeedback;
internal ModuleContent moduleContent;

public abstract string sdkName();

Expand Down Expand Up @@ -199,7 +201,7 @@
}
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();
Expand All @@ -217,7 +219,7 @@
UtilityHelper.CountlyLogging("[CountlyBase] Session Update happening'");
if (Configuration.backendMode) {
moduleBackendMode.OnTimer();
Upload();

Check warning on line 222 in countlyCommon/countlyCommon/CountlyBase.cs

View workflow job for this annotation

GitHub Actions / Feedback + UI unit tests (net8.0-windows)

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
return;
}

Expand Down Expand Up @@ -294,9 +296,11 @@
{
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;
}
Expand All @@ -322,33 +326,38 @@
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;
}
Expand Down Expand Up @@ -1210,6 +1219,9 @@
// modules
moduleBackendMode = null;
moduleRemoteConfig = null;
moduleFeedback = null;
if (moduleContent != null) { moduleContent.ExitContentZone(); } // stop the poll timer
moduleContent = null;
}
if (clearStorage) {
await ClearStorage();
Expand Down Expand Up @@ -1430,7 +1442,7 @@

StoredRequests.Enqueue(sr);
if (!Configuration.backendMode) {
SaveStoredRequests();

Check warning on line 1445 in countlyCommon/countlyCommon/CountlyBase.cs

View workflow job for this annotation

GitHub Actions / Feedback + UI unit tests (net8.0-windows)

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
} else {
UtilityHelper.CountlyLogging("[CountlyBase] AddRequest, Backend mode enabled, request storage disabled");
}
Expand Down Expand Up @@ -1511,6 +1523,8 @@
}

moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl);
moduleFeedback = new ModuleFeedback(requestHelper, ServerUrl);
moduleContent = new ModuleContent(requestHelper, ServerUrl);
UtilityHelper.CountlyLogging("[CountlyBase] Finished 'InitBase'");

await OnInitComplete();
Expand Down Expand Up @@ -1860,6 +1874,10 @@
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;
}
}
}
Expand Down Expand Up @@ -1996,8 +2014,8 @@
/// </returns>
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();
}

Expand All @@ -2011,5 +2029,53 @@

return moduleRemoteConfig;
}

/// <summary>
/// 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 <see cref="MockFeedback"/> is returned instead.
/// </summary>
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;
}

/// <summary>Registers the UI content-display bridge (ungated; no-op if uninitialized).</summary>
public void SetContentDisplay(IContentDisplay display)
{
if (moduleContent != null) { moduleContent.display = display; }
}

/// <summary>
/// Returns the Content interface (experimental). A no-op <see cref="MockContent"/> is
/// returned when backend mode is enabled, the module is unavailable, or Content consent
/// is not given.
/// </summary>
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;
}
}
}
47 changes: 47 additions & 0 deletions countlyCommon/countlyCommon/Entities/ContentModels.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>Parses a /o/sdk/content response; returns null if it has no usable content.</summary>
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
};
}
}
}
Loading
Loading