From 348562df4b79f6f25c46422612a8669d6f2f4059 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 23 Apr 2026 10:02:21 +0300 Subject: [PATCH 1/5] feat: qt demo app w widgets --- examples/qt_demo/.gitignore | 32 + examples/qt_demo/CMakeLists.txt | 29 + ..._Feedback_Widget_Implementation_Guide.html | 512 ++++ examples/qt_demo/README.md | 171 ++ examples/qt_demo/main.cpp | 2075 +++++++++++++++++ 5 files changed, 2819 insertions(+) create mode 100644 examples/qt_demo/.gitignore create mode 100644 examples/qt_demo/CMakeLists.txt create mode 100644 examples/qt_demo/Countly_Feedback_Widget_Implementation_Guide.html create mode 100644 examples/qt_demo/README.md create mode 100644 examples/qt_demo/main.cpp diff --git a/examples/qt_demo/.gitignore b/examples/qt_demo/.gitignore new file mode 100644 index 0000000..a2919a1 --- /dev/null +++ b/examples/qt_demo/.gitignore @@ -0,0 +1,32 @@ +# Build directory +build/ + +# CMake generated files +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +Makefile + +# Qt auto-generated +*_autogen/ +moc_* +ui_* +qrc_* + +# Compiled binaries +*.o +*.obj +*.exe +*.out +*.app + +# IDE files +.vscode/ +.idea/ +*.user +*.swp +*~ +.DS_Store + +# Dev credentials — do not commit +dev_config.hpp diff --git a/examples/qt_demo/CMakeLists.txt b/examples/qt_demo/CMakeLists.txt new file mode 100644 index 0000000..fb11344 --- /dev/null +++ b/examples/qt_demo/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.16) +project(qt_demo) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_AUTOMOC ON) + +find_package(CURL REQUIRED) +find_package(Qt6 REQUIRED COMPONENTS Widgets WebEngineWidgets) + +# Countly SDK paths — defaults to the parent SDK checkout (this demo lives in +# countly-sdk-cpp/examples/qt_demo/). Override with -DCOUNTLY_SDK_DIR=... if +# you keep the SDK somewhere else. +if(NOT COUNTLY_SDK_DIR) + get_filename_component(COUNTLY_SDK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +endif() +set(COUNTLY_BUILD_DIR "${COUNTLY_SDK_DIR}/build") + +add_executable(qt_demo main.cpp) +target_compile_definitions(qt_demo PRIVATE COUNTLY_USE_SQLITE) +target_include_directories(qt_demo PRIVATE + ${COUNTLY_SDK_DIR}/include + ${COUNTLY_SDK_DIR}/vendor/json/include +) +target_link_libraries(qt_demo PRIVATE + CURL::libcurl + Qt6::Widgets + Qt6::WebEngineWidgets + ${COUNTLY_BUILD_DIR}/libcountly.dylib +) diff --git a/examples/qt_demo/Countly_Feedback_Widget_Implementation_Guide.html b/examples/qt_demo/Countly_Feedback_Widget_Implementation_Guide.html new file mode 100644 index 0000000..039d5e7 --- /dev/null +++ b/examples/qt_demo/Countly_Feedback_Widget_Implementation_Guide.html @@ -0,0 +1,512 @@ + + + + +Countly Feedback Widgets - C++ Implementation Guide + + + + +
+ Countly +

Countly Feedback Widgets - C++ Implementation Guide

+
+

+ This guide covers how to implement Countly Feedback Widgets (NPS, Survey, Rating) in a C++ application. + There are two approaches: WebView Presentation (server renders the UI) and Manual Reporting (you build the UI, report results as events). +

+

+ For product details on each widget type, see: +

+ +

+ For SDK implementation specs, see: +

+ + +
+ +

Data Structures

+ +
struct CountlyFeedbackWidget {
+    std::string widgetId;       // from "_id"
+    std::string type;           // "nps", "survey", or "rating"
+    std::string name;           // display name
+    std::vector<std::string> tags; // from "tg" array
+    std::string widgetVersion;  // from "wv" (empty = legacy)
+};
+ +
+ Widget Version (wv): When present, the widget uses fullscreen display + with a webview-driven close button. When absent, it's a legacy widget - you provide the close button. +
+ +
+ +

Step 1: Fetch Available Widgets

+ +

Endpoint

+
GET /o/sdk?method=feedback
+    &app_key=APP_KEY
+    &device_id=DEVICE_ID
+    &sdk_version=SDK_VERSION
+    &sdk_name=SDK_NAME
+ +
+ If parameter tampering is enabled, add the sha256 checksum parameter. + In temporary device ID mode, return an empty list instead. +
+ +

Response

+
{
+  "result": [
+    {
+      "_id": "614871419f030e44be07d82f",
+      "type": "rating",
+      "name": "Leave us a feedback",
+      "tg": ["/"]
+    },
+    {
+      "_id": "614871419f030e44be07d839",
+      "type": "nps",
+      "name": "One response for all",
+      "tg": [],
+      "wv": "a"
+    }
+  ]
+}
+ +

C++ Example

+
// libcurl + nlohmann/json
+std::vector<CountlyFeedbackWidget> fetchWidgets() {
+    std::string url = SERVER_URL + "/o/sdk"
+        "?method=feedback"
+        "&app_key=" + urlEncode(APP_KEY) +
+        "&device_id=" + urlEncode(DEVICE_ID) +
+        "&sdk_version=" + urlEncode(SDK_VERSION) +
+        "&sdk_name=" + urlEncode(SDK_NAME);
+
+    std::string response = httpGet(url);
+    json j = json::parse(response);
+    std::vector<CountlyFeedbackWidget> widgets;
+
+    for (const auto& w : j["result"]) {
+        CountlyFeedbackWidget widget;
+        widget.widgetId      = w.value("_id", "");
+        widget.type          = w.value("type", "");
+        widget.name          = w.value("name", "");
+        widget.widgetVersion = w.value("wv", "");
+
+        if (w.contains("tg") && w["tg"].is_array()) {
+            for (const auto& tag : w["tg"])
+                widget.tags.push_back(tag.get<std::string>());
+        }
+        widgets.push_back(widget);
+    }
+    return widgets;
+}
+ +
+ +

Step 2a: WebView Presentation

+ +

URL Construction

+
/feedback/{type}?widget_id=WIDGET_ID
+    &device_id=DEVICE_ID
+    &app_key=APP_KEY
+    &sdk_version=SDK_VERSION
+    &sdk_name=SDK_NAME
+    &app_version=APP_VERSION
+    &platform=PLATFORM
+    &custom=CUSTOM_JSON
+ +
+ Even if parameter tamper protection is enabled, this URL does not use the checksum parameter. +
+ +

Custom Parameters

+

The custom field is a URL-encoded JSON object:

+ + + + + + +
FlagValueDescriptionWhen
tc1Terms & Conditions link supportAlways
xb1WebView handles its own close buttonwv present
rw1Fullscreen displaywv present
+ + + +

Display Modes

+ + +

C++ Example

+
std::string constructWebViewUrl(const CountlyFeedbackWidget& widget) {
+    json custom;
+    custom["tc"] = 1;
+    if (!widget.widgetVersion.empty()) {
+        custom["xb"] = 1;
+        custom["rw"] = 1;
+    }
+
+    return SERVER_URL + "/feedback/" + widget.type
+        + "?widget_id=" + urlEncode(widget.widgetId)
+        + "&device_id=" + urlEncode(DEVICE_ID)
+        + "&app_key=" + urlEncode(APP_KEY)
+        + "&sdk_version=" + urlEncode(SDK_VERSION)
+        + "&sdk_name=" + urlEncode(SDK_NAME)
+        + "&app_version=" + urlEncode(APP_VERSION)
+        + "&platform=" + urlEncode(PLATFORM)
+        + "&custom=" + urlEncode(custom.dump());
+}
+ +
+ +

WebView Communication Protocol

+ +

+ The widget page communicates with your app through URL interception (not a JS bridge). + The widget triggers navigation to special URLs that your WebView intercepts. +

+

Base communication URL:

+
https://countly_action_event
+

Intercept any URL starting with this prefix, URL-decode it, parse the query params, and handle accordingly.

+ +

Widget Command (Close)

+

Triggered when the widget's built-in close button is pressed (versioned widgets with xb=1):

+
https://countly_action_event/?cly_widget_command=1&close=1
+

Your app should:

+
    +
  1. Close/dismiss the WebView
  2. +
  3. Record a cancel event with "closed":"1" (see Manual Reporting)
  4. +
  5. Notify any developer callback
  6. +
+ +

Action Events

+

Link navigation from within the widget:

+
https://countly_action_event/?cly_x_action_event=1&action=link&link=URL
+

Open the URL in the system browser. If close=1 is also present, dismiss the WebView after.

+ +

External Link Interception

+

+ Any URL with cly_x_int=1 as a query parameter should be opened in the system browser. + This is used for Terms & Conditions and Privacy Policy links. +

+
https://example.com/terms?cly_x_int=1
+
+ These links often use target="_blank", so your WebView must also handle + new window requests (e.g. Qt's createWindow()). +
+ +

Page Load Handling

+
    +
  1. Start the WebView as invisible and non-interactive
  2. +
  3. After full page load, make it visible and interactive
  4. +
  5. If loading takes 60 seconds or more, treat as failure and close
  6. +
  7. Critical resource errors (js, css, png, jpg, jpeg, webp) or SSL errors should also trigger failure
  8. +
+ +

C++ Example - URL Interception (Qt)

+
class CountlyWebPage : public QWebEnginePage {
+public:
+    // Handle target="_blank" links (T&C, privacy policy)
+    QWebEnginePage* createWindow(WebWindowType) override {
+        auto* tempPage = new QWebEnginePage(this->profile(), this);
+        connect(tempPage, &QWebEnginePage::urlChanged,
+            [tempPage](const QUrl& url) {
+                QDesktopServices::openUrl(url);
+                tempPage->deleteLater();
+            });
+        return tempPage;
+    }
+
+protected:
+    bool acceptNavigationRequest(
+        const QUrl& url, NavigationType, bool) override
+    {
+        QString urlStr = QUrl::fromPercentEncoding(url.toEncoded());
+
+        // External link: cly_x_int=1
+        QUrlQuery query(url);
+        if (query.hasQueryItem("cly_x_int")
+            && query.queryItemValue("cly_x_int") == "1") {
+            QDesktopServices::openUrl(url);
+            return false;
+        }
+
+        // Communication URL
+        if (urlStr.startsWith("https://countly_action_event")) {
+            QUrlQuery q{QUrl{urlStr}};
+
+            // Widget close command
+            if (q.hasQueryItem("cly_widget_command")) {
+                // dismiss WebView, record cancel event
+                return false;
+            }
+
+            // Action: link
+            if (q.hasQueryItem("cly_x_action_event")) {
+                QString action = q.queryItemValue("action");
+                if (action == "link")
+                    QDesktopServices::openUrl(
+                        QUrl(q.queryItemValue("link")));
+                return false;
+            }
+            return false;
+        }
+
+        return true; // allow normal navigation
+    }
+};
+ +
+ +

Step 2b: Manual Reporting

+ +

Build your own UI. The flow: fetch widgets (Step 1) → fetch widget data → report results as events.

+ +

Fetch Widget Data

+
GET /o/surveys/{type}/widget
+    ?widget_id=WIDGET_ID
+    &shown=1
+    &sdk_version=SDK_VERSION
+    &sdk_name=SDK_NAME
+    &app_version=APP_VERSION
+    &platform=PLATFORM
+

This is a direct request (not through the event/request queue).

+ +

C++ Example

+
json getFeedbackWidgetData(const CountlyFeedbackWidget& widget) {
+    std::string url = SERVER_URL
+        + "/o/surveys/" + widget.type + "/widget"
+        + "?widget_id=" + urlEncode(widget.widgetId)
+        + "&shown=1"
+        + "&sdk_version=" + urlEncode(SDK_VERSION)
+        + "&sdk_name=" + urlEncode(SDK_NAME)
+        + "&app_version=" + urlEncode(APP_VERSION)
+        + "&platform=" + urlEncode(PLATFORM);
+
+    std::string response = httpGet(url);
+    return json::parse(response);
+}
+ +

Reporting Results

+

Report as an event. The event key depends on widget type:

+ + + + + + +
Widget TypeEvent Key
NPS[CLY]_nps
Survey[CLY]_survey
Rating[CLY]_star_rating
+ +

Segmentation

+

Every widget event includes this base segmentation:

+ + + + + +
KeyValue
platformSDK platform identifier
app_versionHost application version
widget_idThe widget's ID
+

The widgetResult object (user's answers) is merged into this segmentation. +For the full widgetResult structure per widget type, see +A Deeper Look - Widget Strucures.

+ +

Closed Widget

+

If the user closes without completing, report with "closed":"1":

+
{
+  "platform": "desktop",
+  "app_version": "1.0.0",
+  "widget_id": "614871419f030e44be07d82f",
+  "closed": "1"
+}
+ +
+ After recording the event, force flush immediately - don't wait for the event count threshold. +
+ +

C++ Example

+
void reportFeedbackWidget(
+    const CountlyFeedbackWidget& widget,
+    const json& widgetResult)    // null json if closed
+{
+    std::string key;
+    if (widget.type == "nps")    key = "[CLY]_nps";
+    if (widget.type == "survey") key = "[CLY]_survey";
+    if (widget.type == "rating") key = "[CLY]_star_rating";
+
+    json segmentation;
+    segmentation["platform"]    = PLATFORM;
+    segmentation["app_version"] = APP_VERSION;
+    segmentation["widget_id"]   = widget.widgetId;
+
+    if (widgetResult.is_null()) {
+        segmentation["closed"] = "1";
+    } else {
+        for (auto& [k, v] : widgetResult.items()) {
+            segmentation[k] = v;
+        }
+    }
+
+    recordEvent(key, segmentation);
+    flushEventQueue();
+}
+ +
+ +

Quick Reference

+ +

API Endpoints

+ + + + + +
PurposeEndpoint
Fetch widget list/o/sdk?method=feedback&app_key=...&device_id=...
Fetch widget data/o/surveys/{type}/widget?widget_id=...
WebView URL/feedback/{type}?widget_id=...&custom=...
+ +

Custom Parameter Flags

+ + + + + +
FlagMeaningWhen
tc=1Terms & Conditions supportAlways
xb=1WebView handles close buttonwv present
rw=1Fullscreen displaywv present
+ +

Communication URL Patterns

+ + + + + +
PatternAction
countly_action_event/?cly_widget_command=1&close=1Close WebView, record cancel event
countly_action_event/?cly_x_action_event=1&action=link&link=URLOpen URL in system browser
Any URL with cly_x_int=1Open in system browser (T&C)
+ +

Suggested Dependencies

+ + + +
LibraryPurpose
Qt6 WebEngineWidgets or platform WebViewWebView presentation
+ + + diff --git a/examples/qt_demo/README.md b/examples/qt_demo/README.md new file mode 100644 index 0000000..590ed88 --- /dev/null +++ b/examples/qt_demo/README.md @@ -0,0 +1,171 @@ +# Countly C++ SDK Demo App + +A Qt6 desktop application for manually testing all features of the Countly C++ SDK, including SDK Behavior Settings (SBS) and a standalone Feedback Widgets flow. + +## Prerequisites + +| Requirement | Notes | +| ----------- | ----- | +| **CMake** | 3.16 or newer | +| **A C++17 compiler** | AppleClang / Clang / GCC are all fine | +| **Qt 6** | `Widgets` + `WebEngineWidgets` modules are both required | +| **libcurl** | Used by the Feedback Widgets tab for direct HTTP calls | +| **Countly C++ SDK** | Built locally with `-DCOUNTLY_USE_SQLITE=ON` | + +### Installing the dependencies on macOS + +Homebrew's Qt ships as ~40 keg-only sub-packages (`qtbase`, `qtwebengine`, ...). Qt's own CMake config expects them in a single prefix, so install the unified `qt` formula — it symlinks all the sub-kegs into `/opt/homebrew/opt/qt` where `find_package(Qt6)` can discover every component: + +```bash +brew install cmake qt curl +``` + +`brew install qt@6` or installing `qtbase`/`qtwebengine` individually is **not enough** — `find_package(Qt6 REQUIRED COMPONENTS Widgets WebEngineWidgets)` will fail because the per-keg prefixes don't contain each other's config files. If you already have the sub-packages installed, running `brew install qt` is quick; it only adds the symlink farm on top. + +### Installing the dependencies on Linux + +On Debian/Ubuntu: + +```bash +sudo apt install cmake build-essential libcurl4-openssl-dev \ + qt6-base-dev qt6-webengine-dev +``` + +On Fedora/RHEL: + +```bash +sudo dnf install cmake gcc-c++ libcurl-devel \ + qt6-qtbase-devel qt6-qtwebengine-devel +``` + +## Setup + +### 1. Build the Countly C++ SDK + +This demo lives inside the SDK repo at `examples/qt_demo/`. It links against `libcountly.dylib` (macOS) / `libcountly.so` (Linux) produced by the SDK's own build, so build the SDK first with SQLite support: + +```bash +cd /path/to/countly-sdk-cpp # the repo root, two levels above this README +mkdir -p build && cd build +cmake .. -DCOUNTLY_USE_SQLITE=ON +make +``` + +After this, `countly-sdk-cpp/build/` will contain the Countly shared library. The demo's `CMakeLists.txt` discovers the SDK automatically via `${CMAKE_CURRENT_SOURCE_DIR}/../..` — no path flag needed. + +### 2. Create `dev_config.hpp` + +The "Load Dev Config" button in the Init tab pulls credentials from this header so you don't have to retype them every run. It's in `.gitignore` — never commit real credentials. + +Create `examples/qt_demo/dev_config.hpp` with: + +```cpp +#ifndef DEV_CONFIG_HPP +#define DEV_CONFIG_HPP + +#include + +struct DevConfig { + std::string serverUrl = "https://your.server.ly"; + std::string appKey = "YOUR_APP_KEY"; + std::string deviceId = "test-device-id"; + std::string dbPath = "countly_demo.db"; + std::string port = ""; + std::string salt = ""; + std::string eqThreshold = ""; + std::string rqMaxSize = ""; + std::string rqBatchSize = ""; + std::string sessionInterval = ""; + std::string updateInterval = ""; + std::string metricsOs = "macOS"; + std::string metricsOsVersion = "15.0"; + std::string metricsDevice = "MacBook"; + std::string metricsResolution = "2560x1600"; + std::string metricsCarrier = ""; + std::string metricsAppVersion = "1.0"; + std::string sbsJson = ""; + bool manualSession = false; + bool disableSBSUpdates = false; + bool alwaysPost = false; + bool enableRemoteConfig = false; + bool disableAutoEventsOnUP = false; +}; + +#endif +``` + +### 3. Configure and build the demo + +From the `examples/qt_demo/` folder: + +```bash +mkdir -p build && cd build + +cmake \ + -DCMAKE_PREFIX_PATH=/opt/homebrew/opt/qt \ + .. + +make -j$(sysctl -n hw.ncpu) # on Linux: -j$(nproc) +``` + +One flag worth explaining: + +- **`CMAKE_PREFIX_PATH`** tells CMake where Qt lives. On macOS with Homebrew this is `/opt/homebrew/opt/qt` (Apple Silicon) or `/usr/local/opt/qt` (Intel). On Linux with distro packages you can usually omit it entirely — the pkg-config files are already on the default search path. + +If you keep the SDK checkout somewhere other than this demo's grandparent directory (e.g. a separate working tree), pass `-DCOUNTLY_SDK_DIR=/path/to/countly-sdk-cpp` to override the default. + +The binary is written to `build/qt_demo`. + +### 4. Run + +```bash +./qt_demo +``` + +In the app: + +1. Go to **Init / Config**, click **Load Dev Config** (or fill the fields manually), then **Initialize SDK**. +2. Switch tabs to exercise the feature you want to test. The SDK's log output streams into the panel at the bottom in real time. +3. For feedback widgets, head to the **Feedback Widgets** tab and click **Fetch Widgets** — it reuses the Init tab's server URL / app key / device ID. Click any widget card to present it in the embedded web view. + +## Tabs + +The app has 10 tabs covering all SDK features: + +| Tab | Description | +| --- | ----------- | +| **Init / Config** | Server URL, app key, device ID, metrics, SDK options (manual sessions, SBS JSON, salt, etc.). Initialize and stop the SDK from here. | +| **Sessions** | Begin, update, and end sessions manually. | +| **Events** | Send basic events, events with count/sum/duration, and events with custom segmentation. | +| **Views** | Start and stop named views. | +| **Crashes** | Record handled exceptions, add breadcrumbs, set custom crash segments. | +| **User Profile** | Set standard user properties (name, email, etc.) and custom key-value pairs. | +| **Location** | Set country code, city, GPS coordinates, or IP address. Disable location. | +| **Device ID** | Change device ID with or without server merge. | +| **Remote Config** | Fetch all remote config values or fetch specific keys. View the returned JSON. | +| **Feedback Widgets** | Standalone HTTP flow (does not use the SDK). Fetches feedback widgets from the server and renders them in an embedded `QWebEngineView`, intercepting Countly widget communication URLs. Uses the Server URL / App Key / Device ID from the Init tab. See `Countly_Feedback_Widget_Implementation_Guide.html` for the underlying protocol. | + +## Log Panel + +A live log panel at the bottom of the window shows all SDK log output in real time. Logs are delivered via a thread-safe Qt signal/slot connection so background SDK threads can log without crashing the UI. + +## Troubleshooting + +- **`Could NOT find Qt6WebEngineWidgets (missing: Qt6WebEngineWidgets_DIR)`** + You're pointing CMake at a Qt prefix that only contains `qtbase`. Install the unified Homebrew `qt` formula (`brew install qt`) or add the `qtwebengine` prefix alongside in `CMAKE_PREFIX_PATH`. + +- **Build fails after editing `CMakeLists.txt` or moving the SDK** + Delete the `build/` folder and reconfigure. CMake caches `find_package` results; adding a new dependency or changing paths requires a fresh configure, not an incremental rebuild. + +- **`libcountly.dylib` not found at launch** + The build embeds `${COUNTLY_SDK_DIR}/build` as an `LC_RPATH`. Moving the SDK directory after building invalidates that path. Reconfigure (or patch with `install_name_tool -add_rpath`). + +- **WebEngine window is blank / never loads** + On macOS the WebEngine process needs network permissions; make sure your firewall isn't blocking `QtWebEngineProcess`. You can also watch the log panel — the demo logs every navigation interception the page makes. + +## Notes + +- `dev_config.hpp` is in `.gitignore` — never commit credentials. +- The SDK is linked as a dynamic library. Rebuild the SDK first whenever you update it, then rebuild the demo. +- The Feedback Widgets tab intentionally bypasses the C++ SDK — it talks to `/o/sdk?method=feedback` and `/feedback/` directly via libcurl. This mirrors the manual protocol documented in `Countly_Feedback_Widget_Implementation_Guide.html` and is useful for validating the server-side widget setup independently of the SDK. +- On exit the app calls `Countly::getInstance().stop()` before the main window is destroyed so background SDK threads don't call back into a dead GUI. diff --git a/examples/qt_demo/main.cpp b/examples/qt_demo/main.cpp new file mode 100644 index 0000000..f56e749 --- /dev/null +++ b/examples/qt_demo/main.cpp @@ -0,0 +1,2075 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "countly.hpp" +#include "dev_config.hpp" + +using json = nlohmann::json; +using namespace cly; + +// --------------------------------------------------------------------------- +// Global stylesheet +// --------------------------------------------------------------------------- +static const char *GLOBAL_STYLE = R"( + QMainWindow, QWidget { + background: #f5f5f5; + color: #333; + } + QTabWidget::pane { + border: 1px solid #ddd; + background: #ffffff; + border-radius: 4px; + margin-top: -1px; + } + QTabBar { + background: #2c3e50; + } + QTabBar::tab { + background: #34495e; + color: #bdc3c7; + border: none; + padding: 10px 20px; + margin-right: 1px; + font-size: 13px; + font-weight: 500; + min-width: 80px; + } + QTabBar::tab:selected { + background: #ffffff; + color: #2c3e50; + font-weight: bold; + border-top: 3px solid #27ae60; + padding-top: 7px; + } + QTabBar::tab:hover:!selected { + background: #4a6785; + color: #ecf0f1; + } + QGroupBox { + font-weight: bold; + font-size: 13px; + border: 1px solid #ddd; + border-radius: 6px; + margin-top: 12px; + padding-top: 18px; + background: #fafafa; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 12px; + padding: 0 6px; + color: #444; + } + QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox { + border: 1px solid #ccc; + border-radius: 4px; + padding: 6px 8px; + background: #ffffff; + color: #222; + font-size: 13px; + } + QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QSpinBox:focus { + border-color: #4A90D9; + } + QCheckBox { + font-size: 13px; + spacing: 6px; + } + QLabel { + font-size: 13px; + color: #333; + } + QPushButton { + border: 1px solid #ccc; + border-radius: 4px; + padding: 8px 16px; + font-size: 13px; + background: #f0f0f0; + color: #333; + } + QPushButton:hover { + background: #e0e0e0; + } + QPushButton:pressed { + background: #d0d0d0; + } + QListWidget { + border: 1px solid #ccc; + border-radius: 4px; + background: #ffffff; + font-size: 13px; + } +)"; + +static const QString BTN_GREEN = + "QPushButton { background: #27ae60; color: white; border-color: #219a52; }" + "QPushButton:hover { background: #2ecc71; }" + "QPushButton:pressed { background: #1e8449; }"; + +static const QString BTN_RED = + "QPushButton { background: #c0392b; color: white; border-color: #a93226; }" + "QPushButton:hover { background: #e74c3c; }" + "QPushButton:pressed { background: #96281b; }"; + +static const QString BTN_BLUE = + "QPushButton { background: #2980b9; color: white; border-color: #2471a3; }" + "QPushButton:hover { background: #3498db; }" + "QPushButton:pressed { background: #1f6391; }"; + +static const QString BTN_ORANGE = + "QPushButton { background: #e67e22; color: white; border-color: #cf711a; }" + "QPushButton:hover { background: #f39c12; }" + "QPushButton:pressed { background: #ba6617; }"; + +// --------------------------------------------------------------------------- +// Helper: parse JSON segmentation string into map +// --------------------------------------------------------------------------- +static std::map parseSegmentation(const std::string &text) { + std::map result; + if (text.empty()) return result; + try { + auto j = json::parse(text); + for (auto it = j.begin(); it != j.end(); ++it) { + if (it.value().is_string()) { + result[it.key()] = it.value().get(); + } else { + result[it.key()] = it.value().dump(); + } + } + } catch (...) { + // silently ignore parse errors + } + return result; +} + +// --------------------------------------------------------------------------- +// Feedback Widgets — standalone HTTP flow (does not use the C++ SDK) +// --------------------------------------------------------------------------- +// These constants identify this client to the Countly feedback endpoint. +// Server URL / app key / device ID are read from the Init tab at runtime. +static const std::string FW_SDK_VERSION = "1.0.0"; +static const std::string FW_SDK_NAME = "cpp-native"; +static const std::string FW_APP_VERSION = "1.0.0"; +static const std::string FW_PLATFORM = "desktop"; +static const std::string FW_COMM_URL = "https://countly_action_event"; + +struct CountlyFeedbackWidget { + std::string widgetId; + std::string type; + std::string name; + std::vector tags; + std::string widgetVersion; +}; + +static size_t fwWriteCallback(void *contents, size_t size, size_t nmemb, std::string *out) { + size_t totalSize = size * nmemb; + out->append(static_cast(contents), totalSize); + return totalSize; +} + +static std::string fwHttpGet(const std::string &url) { + CURL *curl = curl_easy_init(); + if (!curl) throw std::runtime_error("Failed to init curl"); + + std::string response; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, fwWriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + + CURLcode res = curl_easy_perform(curl); + long httpCode = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) + throw std::runtime_error("HTTP failed: " + std::string(curl_easy_strerror(res))); + if (httpCode != 200) + throw std::runtime_error("HTTP " + std::to_string(httpCode) + ": " + response); + + return response; +} + +static std::string fwUrlEncode(const std::string &value) { + CURL *curl = curl_easy_init(); + if (!curl) return value; + char *encoded = curl_easy_escape(curl, value.c_str(), static_cast(value.length())); + std::string result(encoded); + curl_free(encoded); + curl_easy_cleanup(curl); + return result; +} + +static std::vector fwFetchWidgets( + const std::string &serverUrl, const std::string &appKey, const std::string &deviceId) { + std::string url = serverUrl + "/o/sdk" + "?method=feedback" + "&app_key=" + fwUrlEncode(appKey) + + "&device_id=" + fwUrlEncode(deviceId) + + "&sdk_version=" + fwUrlEncode(FW_SDK_VERSION) + + "&sdk_name=" + fwUrlEncode(FW_SDK_NAME); + + std::string response = fwHttpGet(url); + json j = json::parse(response); + std::vector widgets; + + if (!j.contains("result") || !j["result"].is_array()) return widgets; + + for (const auto &w : j["result"]) { + CountlyFeedbackWidget widget; + widget.widgetId = w.value("_id", ""); + widget.type = w.value("type", ""); + widget.name = w.value("name", ""); + widget.widgetVersion = w.value("wv", ""); + if (w.contains("tg") && w["tg"].is_array()) { + for (const auto &tag : w["tg"]) { + if (tag.is_string()) widget.tags.push_back(tag.get()); + } + } + widgets.push_back(widget); + } + return widgets; +} + +static std::string fwConstructWebViewUrl( + const CountlyFeedbackWidget &widget, + const std::string &serverUrl, const std::string &appKey, const std::string &deviceId) { + json custom; + custom["tc"] = 1; + if (!widget.widgetVersion.empty()) { + custom["xb"] = 1; + custom["rw"] = 1; + } + + return serverUrl + "/feedback/" + widget.type + + "?widget_id=" + fwUrlEncode(widget.widgetId) + + "&device_id=" + fwUrlEncode(deviceId) + + "&app_key=" + fwUrlEncode(appKey) + + "&sdk_version=" + fwUrlEncode(FW_SDK_VERSION) + + "&sdk_name=" + fwUrlEncode(FW_SDK_NAME) + + "&app_version=" + fwUrlEncode(FW_APP_VERSION) + + "&platform=" + fwUrlEncode(FW_PLATFORM) + + "&custom=" + fwUrlEncode(custom.dump()); +} + +// Custom QWebEnginePage that intercepts Countly widget communication URLs. +class CountlyWebPage : public QWebEnginePage { + Q_OBJECT +public: + CountlyFeedbackWidget currentWidget; + std::function onWidgetClosed; + std::function onLog; + + using QWebEnginePage::QWebEnginePage; + + // Handle target="_blank" links (T&C, privacy policy, etc.) + QWebEnginePage *createWindow(WebWindowType) override { + auto *tempPage = new QWebEnginePage(this->profile(), this); + connect(tempPage, &QWebEnginePage::urlChanged, this, [this, tempPage](const QUrl &url) { + if (onLog) onLog("[Widgets][NewWindow] " + url.toString().toStdString()); + QDesktopServices::openUrl(url); + tempPage->deleteLater(); + }); + return tempPage; + } + +protected: + bool acceptNavigationRequest(const QUrl &url, NavigationType, bool) override { + QString urlStr = QUrl::fromPercentEncoding(url.toEncoded()); + if (onLog) onLog("[Widgets][Intercept] " + urlStr.toStdString()); + + // External link interception: cly_x_int=1 + QUrlQuery query(url); + if (query.hasQueryItem("cly_x_int") && query.queryItemValue("cly_x_int") == "1") { + if (onLog) onLog("[Widgets] -> External link, opening in browser"); + QDesktopServices::openUrl(url); + return false; + } + + // Communication URL + if (urlStr.startsWith(QString::fromStdString(FW_COMM_URL))) { + QUrlQuery commQuery{QUrl{urlStr}}; + + // Widget command: close + if (commQuery.hasQueryItem("cly_widget_command") && + commQuery.queryItemValue("cly_widget_command") == "1") { + if (commQuery.hasQueryItem("close") && commQuery.queryItemValue("close") == "1") { + if (onLog) onLog("[Widgets] -> Widget close command"); + if (onWidgetClosed) onWidgetClosed(); + } + return false; + } + + // Actions + if (commQuery.hasQueryItem("cly_x_action_event") && + commQuery.queryItemValue("cly_x_action_event") == "1") { + QString action = commQuery.queryItemValue("action"); + + if (action == "link") { + QString link = commQuery.queryItemValue("link"); + if (onLog) onLog("[Widgets] -> Open link in browser: " + link.toStdString()); + QDesktopServices::openUrl(QUrl(link)); + } + + if (commQuery.hasQueryItem("close") && commQuery.queryItemValue("close") == "1") { + if (onLog) onLog("[Widgets] -> close=1 after action, dismissing"); + if (onWidgetClosed) onWidgetClosed(); + } + return false; + } + + return false; // Block unknown comm URLs + } + + return true; // Allow normal navigation + } +}; + +// Clickable card that previews a single feedback widget in the list. +class WidgetCard : public QFrame { + Q_OBJECT +public: + WidgetCard(const CountlyFeedbackWidget &w, QWidget *parent = nullptr) + : QFrame(parent), widget(w) { + setFrameShape(QFrame::StyledPanel); + setCursor(Qt::PointingHandCursor); + setStyleSheet( + "WidgetCard { background: #ffffff; border: 1px solid #ddd; border-radius: 8px; padding: 12px; }" + "WidgetCard:hover { border-color: #4A90D9; background: #f0f7ff; }" + ); + + auto *layout = new QVBoxLayout(this); + layout->setSpacing(4); + + QString typeStr = QString::fromStdString(w.type).toUpper(); + QString badgeColor = "#888"; + if (w.type == "nps") badgeColor = "#E67E22"; + else if (w.type == "survey") badgeColor = "#2ECC71"; + else if (w.type == "rating") badgeColor = "#3498DB"; + + auto *typeBadge = new QLabel(typeStr); + typeBadge->setStyleSheet(QString( + "background: %1; color: white; border-radius: 4px; padding: 2px 8px; " + "font-size: 11px; font-weight: bold;" + ).arg(badgeColor)); + typeBadge->setFixedWidth(typeBadge->sizeHint().width() + 16); + + QString name = QString::fromStdString(w.name); + if (name.isEmpty()) name = "(unnamed)"; + auto *nameLabel = new QLabel(name); + nameLabel->setStyleSheet( + "font-size: 14px; font-weight: bold; color: #333; " + "border: none; background: transparent;"); + nameLabel->setWordWrap(true); + + QString idStr = QString::fromStdString(w.widgetId); + if (idStr.length() > 12) idStr = idStr.left(12) + "..."; + auto *idLabel = new QLabel("ID: " + idStr); + idLabel->setStyleSheet( + "font-size: 11px; color: #999; border: none; background: transparent;"); + + QString verStr = w.widgetVersion.empty() + ? QString("legacy") + : "v" + QString::fromStdString(w.widgetVersion); + auto *verLabel = new QLabel(verStr); + verLabel->setStyleSheet( + "font-size: 11px; color: #666; border: none; background: transparent;"); + + layout->addWidget(typeBadge); + layout->addWidget(nameLabel); + layout->addWidget(idLabel); + layout->addWidget(verLabel); + } + + CountlyFeedbackWidget widget; + +signals: + void clicked(const CountlyFeedbackWidget &widget); + +protected: + void mousePressEvent(QMouseEvent *) override { + emit clicked(widget); + } +}; + +// --------------------------------------------------------------------------- +// MainWindow +// --------------------------------------------------------------------------- +class MainWindow : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr) : QMainWindow(parent) { + setWindowTitle("Countly C++ SDK Demo"); + resize(1100, 850); + + auto *centralWidget = new QWidget(this); + auto *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->setContentsMargins(10, 10, 10, 10); + mainLayout->setSpacing(8); + + // Splitter: top = tabs, bottom = log + auto *splitter = new QSplitter(Qt::Vertical, centralWidget); + + // Tab widget + tabs = new QTabWidget(); + buildInitTab(); + buildSessionsTab(); + buildEventsTab(); + buildViewsTab(); + buildCrashesTab(); + buildUserProfileTab(); + buildLocationTab(); + buildDeviceIdTab(); + buildRemoteConfigTab(); + buildFeedbackWidgetsTab(); + splitter->addWidget(tabs); + + // Log panel + auto *logContainer = new QWidget(); + auto *logLayout = new QVBoxLayout(logContainer); + logLayout->setContentsMargins(0, 0, 0, 0); + logLayout->setSpacing(4); + + auto *logHeader = new QHBoxLayout(); + auto *logLabel = new QLabel("SDK Log Output"); + logLabel->setStyleSheet("font-weight: bold; font-size: 14px; color: #444;"); + logHeader->addWidget(logLabel); + logHeader->addStretch(); + + auto *clearLogBtn = new QPushButton("Clear Log"); + clearLogBtn->setStyleSheet(BTN_RED); + clearLogBtn->setFixedWidth(100); + connect(clearLogBtn, &QPushButton::clicked, this, [this]() { logOutput->clear(); }); + logHeader->addWidget(clearLogBtn); + logLayout->addLayout(logHeader); + + logOutput = new QPlainTextEdit(); + logOutput->setReadOnly(true); + logOutput->setFont(QFont("Menlo", 11)); + logOutput->setStyleSheet( + "QPlainTextEdit { background: #f8f8f8; color: #333; " + "border: 1px solid #ccc; border-radius: 4px; }"); + logOutput->setMaximumBlockCount(5000); + logLayout->addWidget(logOutput); + splitter->addWidget(logContainer); + + splitter->setStretchFactor(0, 3); + splitter->setStretchFactor(1, 1); + + mainLayout->addWidget(splitter); + setCentralWidget(centralWidget); + } + +signals: + void logMessageReceived(const QString &message); + +public slots: + void appendLog(const QString &message) { + logOutput->appendPlainText(message); + auto cursor = logOutput->textCursor(); + cursor.movePosition(QTextCursor::End); + logOutput->setTextCursor(cursor); + } + +private: + // Widgets + QTabWidget *tabs = nullptr; + QPlainTextEdit *logOutput = nullptr; + + // Init tab + QLineEdit *serverUrlEdit = nullptr; + QLineEdit *appKeyEdit = nullptr; + QLineEdit *deviceIdEdit = nullptr; + QLineEdit *dbPathEdit = nullptr; + QLineEdit *portEdit = nullptr; + QTextEdit *sbsJsonEdit = nullptr; + QCheckBox *manualSessionCheck = nullptr; + QCheckBox *disableSBSCheck = nullptr; + QCheckBox *alwaysPostCheck = nullptr; + QCheckBox *enableRemoteConfigCheck = nullptr; + QCheckBox *disableAutoEventsOnUPCheck = nullptr; + QLineEdit *saltEdit = nullptr; + QLineEdit *eqThresholdEdit = nullptr; + QLineEdit *rqMaxSizeEdit = nullptr; + QLineEdit *rqBatchSizeEdit = nullptr; + QLineEdit *sessionIntervalEdit = nullptr; + QLineEdit *updateIntervalEdit = nullptr; + QLineEdit *metricsOsEdit = nullptr; + QLineEdit *metricsOsVersionEdit = nullptr; + QLineEdit *metricsDeviceEdit = nullptr; + QLineEdit *metricsResolutionEdit = nullptr; + QLineEdit *metricsCarrierEdit = nullptr; + QLineEdit *metricsAppVersionEdit = nullptr; + QPushButton *initBtn = nullptr; + QPushButton *stopBtn = nullptr; + QLabel *sdkStatusLabel = nullptr; + + // Sessions tab + QLabel *sessionStatusLabel = nullptr; + + // Events tab + QLineEdit *eventKeyEdit = nullptr; + QSpinBox *eventCountSpin = nullptr; + QLineEdit *eventSumEdit = nullptr; + QLineEdit *eventSegEdit = nullptr; + + // Views tab + QLineEdit *viewNameEdit = nullptr; + QLineEdit *viewSegEdit = nullptr; + QListWidget *activeViewsList = nullptr; + + // Crashes tab + QLineEdit *crashTitleEdit = nullptr; + QTextEdit *stackTraceEdit = nullptr; + QCheckBox *fatalCheck = nullptr; + QLineEdit *crashOsEdit = nullptr; + QLineEdit *crashSegEdit = nullptr; + QLineEdit *breadcrumbEdit = nullptr; + + // User profile tab + QLineEdit *userNameEdit = nullptr; + QLineEdit *userUsernameEdit = nullptr; + QLineEdit *userEmailEdit = nullptr; + QLineEdit *userPhoneEdit = nullptr; + QLineEdit *userOrgEdit = nullptr; + QLineEdit *userPictureEdit = nullptr; + QLineEdit *userGenderEdit = nullptr; + QLineEdit *userBirthYearEdit = nullptr; + QListWidget *customPropsList = nullptr; + QLineEdit *customKeyEdit = nullptr; + QLineEdit *customValueEdit = nullptr; + + // Location tab + QLineEdit *countryCodeEdit = nullptr; + QLineEdit *cityEdit = nullptr; + QLineEdit *gpsEdit = nullptr; + QLineEdit *ipEdit = nullptr; + + // Device ID tab + QLineEdit *newDeviceIdEdit = nullptr; + QCheckBox *mergeCheck = nullptr; + + // Remote Config tab + QLineEdit *rcKeyEdit = nullptr; + QLabel *rcValueLabel = nullptr; + QLineEdit *rcKeysForEdit = nullptr; + QLineEdit *rcKeysExceptEdit = nullptr; + + // Feedback Widgets tab + QVBoxLayout *fwCardsLayout = nullptr; + QLabel *fwHeaderLabel = nullptr; + QStackedWidget *fwRightStack = nullptr; + QWebEngineView *fwWebView = nullptr; + CountlyWebPage *fwWebPage = nullptr; + QTimer *fwLoadTimer = nullptr; + + // State + bool sdkInitialized = false; + std::map activeViews; // viewId -> viewName + + // ----------------------------------------------------------------------- + // Helper: create a labeled row + // ----------------------------------------------------------------------- + QHBoxLayout *labeledRow(const QString &label, QWidget *widget) { + auto *row = new QHBoxLayout(); + auto *lbl = new QLabel(label); + lbl->setFixedWidth(130); + row->addWidget(lbl); + row->addWidget(widget); + return row; + } + + // ----------------------------------------------------------------------- + // Thread-safe log helper + // ----------------------------------------------------------------------- + void logMsg(const std::string &msg) { + QString qmsg = QString::fromStdString(msg); + QMetaObject::invokeMethod(this, "appendLog", Qt::QueuedConnection, + Q_ARG(QString, qmsg)); + } + + // ----------------------------------------------------------------------- + // Tab 1: Init and Config + // ----------------------------------------------------------------------- + void buildInitTab() { + auto *page = new QWidget(); + auto *scroll = new QScrollArea(); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + + auto *layout = new QVBoxLayout(page); + layout->setSpacing(10); + layout->setContentsMargins(16, 16, 16, 16); + + // Dev config loader + auto *devRow = new QHBoxLayout(); + auto *devBtn = new QPushButton("Load Dev Config"); + devBtn->setStyleSheet(BTN_ORANGE); + devBtn->setToolTip("Loads credentials from dev_config.hpp — edit that file with your test server details"); + connect(devBtn, &QPushButton::clicked, this, &MainWindow::onLoadDevConfig); + devRow->addWidget(devBtn); + auto *devHint = new QLabel("Edit dev_config.hpp with your test server credentials"); + devHint->setStyleSheet("color: #888; font-style: italic; font-size: 12px;"); + devRow->addWidget(devHint); + devRow->addStretch(); + layout->addLayout(devRow); + + // Connection group + auto *connGroup = new QGroupBox("Connection Settings"); + auto *connLayout = new QVBoxLayout(connGroup); + + serverUrlEdit = new QLineEdit("https://your.server.ly"); + connLayout->addLayout(labeledRow("Server URL:", serverUrlEdit)); + + appKeyEdit = new QLineEdit("YOUR_APP_KEY"); + connLayout->addLayout(labeledRow("App Key:", appKeyEdit)); + + deviceIdEdit = new QLineEdit("cpp-demo-device"); + connLayout->addLayout(labeledRow("Device ID:", deviceIdEdit)); + + dbPathEdit = new QLineEdit("countly_demo.db"); + connLayout->addLayout(labeledRow("Database Path:", dbPathEdit)); + + portEdit = new QLineEdit(); + portEdit->setPlaceholderText("Default: 0 (auto from URL)"); + connLayout->addLayout(labeledRow("Port:", portEdit)); + + layout->addWidget(connGroup); + + // SBS group + auto *sbsGroup = new QGroupBox("SDK Behavior Settings (SBS)"); + auto *sbsLayout = new QVBoxLayout(sbsGroup); + + auto *sbsLabel = new QLabel("SBS JSON (pre-init only):"); + sbsLayout->addWidget(sbsLabel); + + sbsJsonEdit = new QTextEdit(); + sbsJsonEdit->setPlaceholderText( + "Paste SBS JSON here, e.g. {\"key\": \"value\"}"); + sbsJsonEdit->setFixedHeight(100); + sbsLayout->addWidget(sbsJsonEdit); + + layout->addWidget(sbsGroup); + + // Tuning group + auto *tuneGroup = new QGroupBox("SDK Tuning"); + auto *tuneLayout = new QVBoxLayout(tuneGroup); + + saltEdit = new QLineEdit(); + saltEdit->setPlaceholderText("Optional — parameter tampering salt"); + tuneLayout->addLayout(labeledRow("Salt:", saltEdit)); + + eqThresholdEdit = new QLineEdit(); + eqThresholdEdit->setPlaceholderText("Default: 100 (1–10000)"); + tuneLayout->addLayout(labeledRow("EQ Threshold:", eqThresholdEdit)); + + rqMaxSizeEdit = new QLineEdit(); + rqMaxSizeEdit->setPlaceholderText("Default: 1000"); + tuneLayout->addLayout(labeledRow("RQ Max Size:", rqMaxSizeEdit)); + + rqBatchSizeEdit = new QLineEdit(); + rqBatchSizeEdit->setPlaceholderText("Default: 100"); + tuneLayout->addLayout(labeledRow("RQ Batch Size:", rqBatchSizeEdit)); + + sessionIntervalEdit = new QLineEdit(); + sessionIntervalEdit->setPlaceholderText("Default: 60 (seconds)"); + tuneLayout->addLayout(labeledRow("Session Interval:", sessionIntervalEdit)); + + updateIntervalEdit = new QLineEdit(); + updateIntervalEdit->setPlaceholderText("Default: 3000 (milliseconds)"); + tuneLayout->addLayout(labeledRow("Update Loop (ms):", updateIntervalEdit)); + + layout->addWidget(tuneGroup); + + // Metrics group + auto *metricsGroup = new QGroupBox("Device Metrics"); + auto *metricsLayout = new QVBoxLayout(metricsGroup); + + metricsOsEdit = new QLineEdit("macOS"); + metricsLayout->addLayout(labeledRow("OS:", metricsOsEdit)); + + metricsOsVersionEdit = new QLineEdit("15.0"); + metricsLayout->addLayout(labeledRow("OS Version:", metricsOsVersionEdit)); + + metricsDeviceEdit = new QLineEdit("MacBook"); + metricsLayout->addLayout(labeledRow("Device:", metricsDeviceEdit)); + + metricsResolutionEdit = new QLineEdit("2560x1600"); + metricsLayout->addLayout(labeledRow("Resolution:", metricsResolutionEdit)); + + metricsCarrierEdit = new QLineEdit(); + metricsCarrierEdit->setPlaceholderText("Optional"); + metricsLayout->addLayout(labeledRow("Carrier:", metricsCarrierEdit)); + + metricsAppVersionEdit = new QLineEdit("1.0"); + metricsLayout->addLayout(labeledRow("App Version:", metricsAppVersionEdit)); + + layout->addWidget(metricsGroup); + + // Options group + auto *optGroup = new QGroupBox("Options"); + auto *optLayout = new QVBoxLayout(optGroup); + + manualSessionCheck = new QCheckBox("Manual Session Control"); + optLayout->addWidget(manualSessionCheck); + + disableSBSCheck = new QCheckBox("Disable SBS Updates"); + optLayout->addWidget(disableSBSCheck); + + alwaysPostCheck = new QCheckBox("Always Use POST"); + optLayout->addWidget(alwaysPostCheck); + + enableRemoteConfigCheck = new QCheckBox("Enable Remote Config"); + optLayout->addWidget(enableRemoteConfigCheck); + + disableAutoEventsOnUPCheck = new QCheckBox("Disable Auto Events on User Properties"); + optLayout->addWidget(disableAutoEventsOnUPCheck); + + layout->addWidget(optGroup); + + // Buttons + auto *btnRow = new QHBoxLayout(); + + initBtn = new QPushButton("Initialize SDK"); + initBtn->setStyleSheet(BTN_GREEN); + connect(initBtn, &QPushButton::clicked, this, &MainWindow::onInitSDK); + btnRow->addWidget(initBtn); + + stopBtn = new QPushButton("Stop SDK"); + stopBtn->setStyleSheet(BTN_RED); + stopBtn->setEnabled(false); + connect(stopBtn, &QPushButton::clicked, this, &MainWindow::onStopSDK); + btnRow->addWidget(stopBtn); + + btnRow->addStretch(); + layout->addLayout(btnRow); + + // Status + sdkStatusLabel = new QLabel("Status: Not initialized"); + sdkStatusLabel->setStyleSheet( + "font-weight: bold; color: #888; font-size: 14px; padding: 8px 0;"); + layout->addWidget(sdkStatusLabel); + + layout->addStretch(); + + scroll->setWidget(page); + tabs->addTab(scroll, "Init / Config"); + } + + // ----------------------------------------------------------------------- + // Tab 2: Sessions + // ----------------------------------------------------------------------- + void buildSessionsTab() { + auto *page = new QWidget(); + auto *layout = new QVBoxLayout(page); + layout->setSpacing(10); + layout->setContentsMargins(16, 16, 16, 16); + + auto *group = new QGroupBox("Session Control"); + auto *gLayout = new QVBoxLayout(group); + + auto *infoLabel = new QLabel( + "Manual session control must be enabled before SDK init to use these."); + infoLabel->setStyleSheet("color: #888; font-style: italic;"); + infoLabel->setWordWrap(true); + gLayout->addWidget(infoLabel); + + auto *btnRow = new QHBoxLayout(); + + auto *beginBtn = new QPushButton("Begin Session"); + beginBtn->setStyleSheet(BTN_GREEN); + connect(beginBtn, &QPushButton::clicked, this, &MainWindow::onBeginSession); + btnRow->addWidget(beginBtn); + + auto *updateBtn = new QPushButton("Update Session"); + updateBtn->setStyleSheet(BTN_BLUE); + connect(updateBtn, &QPushButton::clicked, this, &MainWindow::onUpdateSession); + btnRow->addWidget(updateBtn); + + auto *endBtn = new QPushButton("End Session"); + endBtn->setStyleSheet(BTN_RED); + connect(endBtn, &QPushButton::clicked, this, &MainWindow::onEndSession); + btnRow->addWidget(endBtn); + + btnRow->addStretch(); + gLayout->addLayout(btnRow); + + sessionStatusLabel = new QLabel("Session: idle"); + sessionStatusLabel->setStyleSheet( + "font-weight: bold; color: #888; font-size: 14px; padding: 8px 0;"); + gLayout->addWidget(sessionStatusLabel); + + layout->addWidget(group); + layout->addStretch(); + tabs->addTab(page, "Sessions"); + } + + // ----------------------------------------------------------------------- + // Tab 3: Events + // ----------------------------------------------------------------------- + void buildEventsTab() { + auto *page = new QWidget(); + auto *layout = new QVBoxLayout(page); + layout->setSpacing(10); + layout->setContentsMargins(16, 16, 16, 16); + + auto *group = new QGroupBox("Record Events"); + auto *gLayout = new QVBoxLayout(group); + + eventKeyEdit = new QLineEdit("test_event"); + gLayout->addLayout(labeledRow("Event Key:", eventKeyEdit)); + + eventCountSpin = new QSpinBox(); + eventCountSpin->setRange(1, 100000); + eventCountSpin->setValue(1); + gLayout->addLayout(labeledRow("Count:", eventCountSpin)); + + eventSumEdit = new QLineEdit(); + eventSumEdit->setPlaceholderText("Optional (e.g. 9.99)"); + gLayout->addLayout(labeledRow("Sum:", eventSumEdit)); + + eventSegEdit = new QLineEdit(); + eventSegEdit->setPlaceholderText("{\"key\": \"value\"}"); + gLayout->addLayout(labeledRow("Segmentation:", eventSegEdit)); + + auto *btnRow = new QHBoxLayout(); + + auto *recordBtn = new QPushButton("Record Event"); + recordBtn->setStyleSheet(BTN_GREEN); + connect(recordBtn, &QPushButton::clicked, this, &MainWindow::onRecordEvent); + btnRow->addWidget(recordBtn); + + auto *record10Btn = new QPushButton("Record 10 Events"); + record10Btn->setStyleSheet(BTN_BLUE); + connect(record10Btn, &QPushButton::clicked, this, &MainWindow::onRecord10Events); + btnRow->addWidget(record10Btn); + + auto *flushBtn = new QPushButton("Flush Events"); + flushBtn->setStyleSheet(BTN_ORANGE); + connect(flushBtn, &QPushButton::clicked, this, &MainWindow::onFlushEvents); + btnRow->addWidget(flushBtn); + + btnRow->addStretch(); + gLayout->addLayout(btnRow); + + layout->addWidget(group); + + // Queue info + auto *queueGroup = new QGroupBox("Queue Status"); + auto *qLayout = new QVBoxLayout(queueGroup); + + auto *checkBtn = new QPushButton("Check Queue Sizes"); + checkBtn->setStyleSheet(BTN_BLUE); + connect(checkBtn, &QPushButton::clicked, this, [this]() { + if (!sdkInitialized) { + logMsg("[App] SDK not initialized"); + return; + } + try { + auto &countly = Countly::getInstance(); + int eq = countly.checkEQSize(); + int rq = countly.checkRQSize(); + logMsg("[App] Event Queue size: " + std::to_string(eq) + + ", Request Queue size: " + std::to_string(rq)); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + }); + qLayout->addWidget(checkBtn); + + layout->addWidget(queueGroup); + layout->addStretch(); + tabs->addTab(page, "Events"); + } + + // ----------------------------------------------------------------------- + // Tab 4: Views + // ----------------------------------------------------------------------- + void buildViewsTab() { + auto *page = new QWidget(); + auto *layout = new QVBoxLayout(page); + layout->setSpacing(10); + layout->setContentsMargins(16, 16, 16, 16); + + auto *openGroup = new QGroupBox("Open View"); + auto *oLayout = new QVBoxLayout(openGroup); + + viewNameEdit = new QLineEdit("MainScreen"); + oLayout->addLayout(labeledRow("View Name:", viewNameEdit)); + + viewSegEdit = new QLineEdit(); + viewSegEdit->setPlaceholderText("{\"key\": \"value\"}"); + oLayout->addLayout(labeledRow("Segmentation:", viewSegEdit)); + + auto *openBtnRow = new QHBoxLayout(); + + auto *openBtn = new QPushButton("Open View"); + openBtn->setStyleSheet(BTN_GREEN); + connect(openBtn, &QPushButton::clicked, this, &MainWindow::onOpenView); + openBtnRow->addWidget(openBtn); + + auto *closeByNameBtn = new QPushButton("Close View by Name"); + closeByNameBtn->setStyleSheet(BTN_RED); + connect(closeByNameBtn, &QPushButton::clicked, + this, &MainWindow::onCloseViewByName); + openBtnRow->addWidget(closeByNameBtn); + + openBtnRow->addStretch(); + oLayout->addLayout(openBtnRow); + layout->addWidget(openGroup); + + // Active views + auto *activeGroup = new QGroupBox("Active Views"); + auto *aLayout = new QVBoxLayout(activeGroup); + + activeViewsList = new QListWidget(); + activeViewsList->setMinimumHeight(120); + aLayout->addWidget(activeViewsList); + + auto *closeSelectedBtn = new QPushButton("Close Selected View"); + closeSelectedBtn->setStyleSheet(BTN_RED); + connect(closeSelectedBtn, &QPushButton::clicked, + this, &MainWindow::onCloseSelectedView); + aLayout->addWidget(closeSelectedBtn); + + layout->addWidget(activeGroup); + layout->addStretch(); + tabs->addTab(page, "Views"); + } + + // ----------------------------------------------------------------------- + // Tab 5: Crashes + // ----------------------------------------------------------------------- + void buildCrashesTab() { + auto *page = new QWidget(); + auto *scroll = new QScrollArea(); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + + auto *layout = new QVBoxLayout(page); + layout->setSpacing(10); + layout->setContentsMargins(16, 16, 16, 16); + + // Breadcrumbs + auto *bcGroup = new QGroupBox("Breadcrumbs"); + auto *bcLayout = new QVBoxLayout(bcGroup); + + breadcrumbEdit = new QLineEdit(); + breadcrumbEdit->setPlaceholderText("Enter a breadcrumb message"); + bcLayout->addLayout(labeledRow("Breadcrumb:", breadcrumbEdit)); + + auto *addBcBtn = new QPushButton("Add Breadcrumb"); + addBcBtn->setStyleSheet(BTN_BLUE); + connect(addBcBtn, &QPushButton::clicked, this, &MainWindow::onAddBreadcrumb); + bcLayout->addWidget(addBcBtn); + layout->addWidget(bcGroup); + + // Exception + auto *exGroup = new QGroupBox("Record Exception"); + auto *exLayout = new QVBoxLayout(exGroup); + + crashTitleEdit = new QLineEdit("NullPointerException"); + exLayout->addLayout(labeledRow("Crash Title:", crashTitleEdit)); + + auto *stLabel = new QLabel("Stack Trace:"); + exLayout->addWidget(stLabel); + stackTraceEdit = new QTextEdit(); + stackTraceEdit->setPlaceholderText( + "com.example.app.Main.run(Main.java:42)\n" + "com.example.app.App.start(App.java:10)"); + stackTraceEdit->setFixedHeight(100); + exLayout->addWidget(stackTraceEdit); + + fatalCheck = new QCheckBox("Fatal"); + fatalCheck->setChecked(false); + exLayout->addWidget(fatalCheck); + + crashOsEdit = new QLineEdit("macOS 15.0"); + exLayout->addLayout(labeledRow("OS Metric:", crashOsEdit)); + + crashSegEdit = new QLineEdit(); + crashSegEdit->setPlaceholderText("{\"module\": \"network\"}"); + exLayout->addLayout(labeledRow("Segmentation:", crashSegEdit)); + + auto *recordExBtn = new QPushButton("Record Exception"); + recordExBtn->setStyleSheet(BTN_RED); + connect(recordExBtn, &QPushButton::clicked, + this, &MainWindow::onRecordException); + exLayout->addWidget(recordExBtn); + + layout->addWidget(exGroup); + layout->addStretch(); + + scroll->setWidget(page); + tabs->addTab(scroll, "Crashes"); + } + + // ----------------------------------------------------------------------- + // Tab 6: User Profile + // ----------------------------------------------------------------------- + void buildUserProfileTab() { + auto *page = new QWidget(); + auto *scroll = new QScrollArea(); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + + auto *layout = new QVBoxLayout(page); + layout->setSpacing(10); + layout->setContentsMargins(16, 16, 16, 16); + + // Named properties + auto *namedGroup = new QGroupBox("Named User Properties"); + auto *nLayout = new QVBoxLayout(namedGroup); + + userNameEdit = new QLineEdit(); + userNameEdit->setPlaceholderText("John Doe"); + nLayout->addLayout(labeledRow("Name:", userNameEdit)); + + userUsernameEdit = new QLineEdit(); + userUsernameEdit->setPlaceholderText("johndoe"); + nLayout->addLayout(labeledRow("Username:", userUsernameEdit)); + + userEmailEdit = new QLineEdit(); + userEmailEdit->setPlaceholderText("john@example.com"); + nLayout->addLayout(labeledRow("Email:", userEmailEdit)); + + userPhoneEdit = new QLineEdit(); + userPhoneEdit->setPlaceholderText("+1234567890"); + nLayout->addLayout(labeledRow("Phone:", userPhoneEdit)); + + userOrgEdit = new QLineEdit(); + userOrgEdit->setPlaceholderText("Acme Corp"); + nLayout->addLayout(labeledRow("Organization:", userOrgEdit)); + + userPictureEdit = new QLineEdit(); + userPictureEdit->setPlaceholderText("https://example.com/pic.jpg"); + nLayout->addLayout(labeledRow("Picture URL:", userPictureEdit)); + + userGenderEdit = new QLineEdit(); + userGenderEdit->setPlaceholderText("M or F"); + nLayout->addLayout(labeledRow("Gender:", userGenderEdit)); + + userBirthYearEdit = new QLineEdit(); + userBirthYearEdit->setPlaceholderText("1990"); + nLayout->addLayout(labeledRow("Birth Year:", userBirthYearEdit)); + + auto *setUserBtn = new QPushButton("Set User Details"); + setUserBtn->setStyleSheet(BTN_GREEN); + connect(setUserBtn, &QPushButton::clicked, + this, &MainWindow::onSetUserDetails); + nLayout->addWidget(setUserBtn); + + layout->addWidget(namedGroup); + + // Custom properties + auto *customGroup = new QGroupBox("Custom User Properties"); + auto *cLayout = new QVBoxLayout(customGroup); + + customPropsList = new QListWidget(); + customPropsList->setMinimumHeight(80); + cLayout->addWidget(customPropsList); + + auto *addRow = new QHBoxLayout(); + customKeyEdit = new QLineEdit(); + customKeyEdit->setPlaceholderText("Key"); + addRow->addWidget(customKeyEdit); + customValueEdit = new QLineEdit(); + customValueEdit->setPlaceholderText("Value"); + addRow->addWidget(customValueEdit); + + auto *addPropBtn = new QPushButton("Add"); + addPropBtn->setStyleSheet(BTN_BLUE); + addPropBtn->setFixedWidth(60); + connect(addPropBtn, &QPushButton::clicked, this, [this]() { + QString key = customKeyEdit->text().trimmed(); + QString val = customValueEdit->text().trimmed(); + if (key.isEmpty()) return; + customPropsList->addItem(key + " = " + val); + customKeyEdit->clear(); + customValueEdit->clear(); + }); + addRow->addWidget(addPropBtn); + + auto *removePropBtn = new QPushButton("Remove"); + removePropBtn->setStyleSheet(BTN_RED); + removePropBtn->setFixedWidth(80); + connect(removePropBtn, &QPushButton::clicked, this, [this]() { + auto *item = customPropsList->currentItem(); + if (item) delete item; + }); + addRow->addWidget(removePropBtn); + + cLayout->addLayout(addRow); + + auto *setCustomBtn = new QPushButton("Set Custom User Details"); + setCustomBtn->setStyleSheet(BTN_GREEN); + connect(setCustomBtn, &QPushButton::clicked, + this, &MainWindow::onSetCustomUserDetails); + cLayout->addWidget(setCustomBtn); + + layout->addWidget(customGroup); + layout->addStretch(); + + scroll->setWidget(page); + tabs->addTab(scroll, "User Profile"); + } + + // ----------------------------------------------------------------------- + // Tab 7: Location + // ----------------------------------------------------------------------- + void buildLocationTab() { + auto *page = new QWidget(); + auto *layout = new QVBoxLayout(page); + layout->setSpacing(10); + layout->setContentsMargins(16, 16, 16, 16); + + auto *group = new QGroupBox("Set Location"); + auto *gLayout = new QVBoxLayout(group); + + countryCodeEdit = new QLineEdit(); + countryCodeEdit->setPlaceholderText("US"); + gLayout->addLayout(labeledRow("Country Code:", countryCodeEdit)); + + cityEdit = new QLineEdit(); + cityEdit->setPlaceholderText("New York"); + gLayout->addLayout(labeledRow("City:", cityEdit)); + + gpsEdit = new QLineEdit(); + gpsEdit->setPlaceholderText("40.7128,-74.0060"); + gLayout->addLayout(labeledRow("GPS Coordinates:", gpsEdit)); + + ipEdit = new QLineEdit(); + ipEdit->setPlaceholderText("192.168.1.1"); + gLayout->addLayout(labeledRow("IP Address:", ipEdit)); + + auto *setLocBtn = new QPushButton("Set Location"); + setLocBtn->setStyleSheet(BTN_GREEN); + connect(setLocBtn, &QPushButton::clicked, this, &MainWindow::onSetLocation); + gLayout->addWidget(setLocBtn); + + layout->addWidget(group); + layout->addStretch(); + tabs->addTab(page, "Location"); + } + + // ----------------------------------------------------------------------- + // Tab 8: Device ID + // ----------------------------------------------------------------------- + void buildDeviceIdTab() { + auto *page = new QWidget(); + auto *layout = new QVBoxLayout(page); + layout->setSpacing(10); + layout->setContentsMargins(16, 16, 16, 16); + + auto *group = new QGroupBox("Change Device ID"); + auto *gLayout = new QVBoxLayout(group); + + newDeviceIdEdit = new QLineEdit(); + newDeviceIdEdit->setPlaceholderText("new-device-id"); + gLayout->addLayout(labeledRow("New Device ID:", newDeviceIdEdit)); + + mergeCheck = new QCheckBox("Merge (same_user = true)"); + gLayout->addWidget(mergeCheck); + + auto *changeBtn = new QPushButton("Change Device ID"); + changeBtn->setStyleSheet(BTN_ORANGE); + connect(changeBtn, &QPushButton::clicked, + this, &MainWindow::onChangeDeviceId); + gLayout->addWidget(changeBtn); + + layout->addWidget(group); + layout->addStretch(); + tabs->addTab(page, "Device ID"); + } + + // ----------------------------------------------------------------------- + // Tab 9: Remote Config + // ----------------------------------------------------------------------- + void buildRemoteConfigTab() { + auto *page = new QWidget(); + auto *layout = new QVBoxLayout(page); + layout->setSpacing(10); + layout->setContentsMargins(16, 16, 16, 16); + + auto *infoLabel = new QLabel( + "Remote Config must be enabled in Init / Config before SDK initialization."); + infoLabel->setStyleSheet("color: #888; font-style: italic;"); + infoLabel->setWordWrap(true); + layout->addWidget(infoLabel); + + // Fetch all + auto *fetchGroup = new QGroupBox("Fetch Remote Config"); + auto *fetchLayout = new QVBoxLayout(fetchGroup); + + auto *fetchAllBtn = new QPushButton("Update Remote Config (all keys)"); + fetchAllBtn->setStyleSheet(BTN_BLUE); + connect(fetchAllBtn, &QPushButton::clicked, this, [this]() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + logMsg("[App] Fetching remote config (all keys)..."); + try { + Countly::getInstance().updateRemoteConfig(); + logMsg("[App] Remote config update requested."); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + }); + fetchLayout->addWidget(fetchAllBtn); + + // Fetch for specific keys + rcKeysForEdit = new QLineEdit(); + rcKeysForEdit->setPlaceholderText("Comma-separated keys, e.g. color,timeout"); + fetchLayout->addLayout(labeledRow("Keys (include):", rcKeysForEdit)); + + auto *fetchForBtn = new QPushButton("Update Remote Config (specific keys)"); + fetchForBtn->setStyleSheet(BTN_BLUE); + connect(fetchForBtn, &QPushButton::clicked, this, [this]() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + std::string keysStr = rcKeysForEdit->text().trimmed().toStdString(); + if (keysStr.empty()) { logMsg("[App] Enter at least one key."); return; } + std::vector keys; + std::istringstream ss(keysStr); + std::string token; + while (std::getline(ss, token, ',')) { + token.erase(0, token.find_first_not_of(' ')); + token.erase(token.find_last_not_of(' ') + 1); + if (!token.empty()) keys.push_back(token); + } + logMsg("[App] Fetching remote config for " + std::to_string(keys.size()) + " keys..."); + try { + Countly::getInstance().updateRemoteConfigFor(keys.data(), keys.size()); + logMsg("[App] Remote config update (for keys) requested."); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + }); + fetchLayout->addWidget(fetchForBtn); + + // Fetch except specific keys + rcKeysExceptEdit = new QLineEdit(); + rcKeysExceptEdit->setPlaceholderText("Comma-separated keys to exclude"); + fetchLayout->addLayout(labeledRow("Keys (exclude):", rcKeysExceptEdit)); + + auto *fetchExceptBtn = new QPushButton("Update Remote Config (except keys)"); + fetchExceptBtn->setStyleSheet(BTN_BLUE); + connect(fetchExceptBtn, &QPushButton::clicked, this, [this]() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + std::string keysStr = rcKeysExceptEdit->text().trimmed().toStdString(); + if (keysStr.empty()) { logMsg("[App] Enter at least one key."); return; } + std::vector keys; + std::istringstream ss(keysStr); + std::string token; + while (std::getline(ss, token, ',')) { + token.erase(0, token.find_first_not_of(' ')); + token.erase(token.find_last_not_of(' ') + 1); + if (!token.empty()) keys.push_back(token); + } + logMsg("[App] Fetching remote config except " + std::to_string(keys.size()) + " keys..."); + try { + Countly::getInstance().updateRemoteConfigExcept(keys.data(), keys.size()); + logMsg("[App] Remote config update (except keys) requested."); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + }); + fetchLayout->addWidget(fetchExceptBtn); + + layout->addWidget(fetchGroup); + + // Get value + auto *getGroup = new QGroupBox("Get Remote Config Value"); + auto *getLayout = new QVBoxLayout(getGroup); + + rcKeyEdit = new QLineEdit(); + rcKeyEdit->setPlaceholderText("Key name, e.g. color"); + getLayout->addLayout(labeledRow("Key:", rcKeyEdit)); + + auto *getBtn = new QPushButton("Get Value"); + getBtn->setStyleSheet(BTN_GREEN); + connect(getBtn, &QPushButton::clicked, this, [this]() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + std::string key = rcKeyEdit->text().trimmed().toStdString(); + if (key.empty()) { logMsg("[App] Enter a key."); return; } + try { + auto val = Countly::getInstance().getRemoteConfigValue(key); + std::string valStr = val.dump(); + rcValueLabel->setText("Value: " + QString::fromStdString(valStr)); + logMsg("[App] Remote config [" + key + "] = " + valStr); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + }); + getLayout->addWidget(getBtn); + + rcValueLabel = new QLabel("Value: (none)"); + rcValueLabel->setStyleSheet("font-weight: bold; font-size: 14px; padding: 8px 0; color: #2c3e50;"); + rcValueLabel->setWordWrap(true); + rcValueLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + getLayout->addWidget(rcValueLabel); + + layout->addWidget(getGroup); + layout->addStretch(); + tabs->addTab(page, "Remote Config"); + } + + // ----------------------------------------------------------------------- + // Tab 10: Feedback Widgets + // ----------------------------------------------------------------------- + // Standalone HTTP flow — does not go through the C++ SDK. Reads server + // URL / app key / device ID from the Init tab. + void buildFeedbackWidgetsTab() { + auto *page = new QWidget(); + auto *layout = new QVBoxLayout(page); + layout->setSpacing(8); + layout->setContentsMargins(10, 10, 10, 10); + + auto *infoLabel = new QLabel( + "Fetches feedback widgets directly via HTTP (no SDK call). " + "Uses the Server URL, App Key, and Device ID from the Init tab."); + infoLabel->setStyleSheet("color: #888; font-style: italic;"); + infoLabel->setWordWrap(true); + layout->addWidget(infoLabel); + + auto *fetchBtn = new QPushButton("Fetch Widgets"); + fetchBtn->setStyleSheet(BTN_BLUE); + fetchBtn->setFixedWidth(160); + connect(fetchBtn, &QPushButton::clicked, this, &MainWindow::onFetchFeedbackWidgets); + layout->addWidget(fetchBtn); + + auto *splitter = new QSplitter(Qt::Horizontal, page); + + // Left: card list (populated on fetch) + auto *scrollArea = new QScrollArea(); + scrollArea->setWidgetResizable(true); + scrollArea->setMinimumWidth(280); + scrollArea->setMaximumWidth(380); + scrollArea->setStyleSheet("QScrollArea { background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; }"); + + auto *cardContainer = new QWidget(); + fwCardsLayout = new QVBoxLayout(cardContainer); + fwCardsLayout->setSpacing(8); + fwCardsLayout->setContentsMargins(10, 10, 10, 10); + + fwHeaderLabel = new QLabel("Widgets (0)"); + fwHeaderLabel->setStyleSheet( + "font-size: 14px; font-weight: bold; color: #333; padding: 4px 0;"); + fwCardsLayout->addWidget(fwHeaderLabel); + fwCardsLayout->addStretch(); + + scrollArea->setWidget(cardContainer); + splitter->addWidget(scrollArea); + + // Right: placeholder or web view + fwRightStack = new QStackedWidget(); + + auto *placeholder = new QLabel("Click a widget on the left to present it here."); + placeholder->setAlignment(Qt::AlignCenter); + placeholder->setStyleSheet("font-size: 14px; color: #999; background: #fafafa;"); + fwRightStack->addWidget(placeholder); + + fwWebView = new QWebEngineView(); + fwWebPage = new CountlyWebPage(fwWebView->page()->profile(), fwWebView); + fwWebPage->onLog = [this](const std::string &msg) { logMsg(msg); }; + fwWebPage->onWidgetClosed = [this]() { + QTimer::singleShot(0, this, [this]() { + fwWebView->setUrl(QUrl("about:blank")); + fwRightStack->setCurrentIndex(0); + logMsg("[Widgets] Widget closed, back to card list"); + }); + }; + fwWebView->setPage(fwWebPage); + fwWebView->setVisible(false); + fwRightStack->addWidget(fwWebView); + + splitter->addWidget(fwRightStack); + splitter->setStretchFactor(0, 0); + splitter->setStretchFactor(1, 1); + + layout->addWidget(splitter, 1); + + fwLoadTimer = new QTimer(this); + fwLoadTimer->setSingleShot(true); + fwLoadTimer->setInterval(60000); + connect(fwLoadTimer, &QTimer::timeout, this, [this]() { + logMsg("[Widgets] Page load exceeded 60s, closing"); + fwWebView->setUrl(QUrl("about:blank")); + fwRightStack->setCurrentIndex(0); + }); + + connect(fwWebView, &QWebEngineView::loadFinished, this, [this](bool ok) { + fwLoadTimer->stop(); + if (ok) { + logMsg("[Widgets] Page loaded"); + fwWebView->setVisible(true); + fwRightStack->setCurrentIndex(1); + } else { + logMsg("[Widgets] Page load failed"); + fwRightStack->setCurrentIndex(0); + } + }); + + tabs->addTab(page, "Feedback Widgets"); + } + + // ----------------------------------------------------------------------- + // SDK Actions + // ----------------------------------------------------------------------- + + void onLoadDevConfig() { + DevConfig dc; + serverUrlEdit->setText(QString::fromStdString(dc.serverUrl)); + appKeyEdit->setText(QString::fromStdString(dc.appKey)); + deviceIdEdit->setText(QString::fromStdString(dc.deviceId)); + dbPathEdit->setText(QString::fromStdString(dc.dbPath)); + portEdit->setText(QString::fromStdString(dc.port)); + saltEdit->setText(QString::fromStdString(dc.salt)); + eqThresholdEdit->setText(QString::fromStdString(dc.eqThreshold)); + rqMaxSizeEdit->setText(QString::fromStdString(dc.rqMaxSize)); + rqBatchSizeEdit->setText(QString::fromStdString(dc.rqBatchSize)); + sessionIntervalEdit->setText(QString::fromStdString(dc.sessionInterval)); + updateIntervalEdit->setText(QString::fromStdString(dc.updateInterval)); + metricsOsEdit->setText(QString::fromStdString(dc.metricsOs)); + metricsOsVersionEdit->setText(QString::fromStdString(dc.metricsOsVersion)); + metricsDeviceEdit->setText(QString::fromStdString(dc.metricsDevice)); + metricsResolutionEdit->setText(QString::fromStdString(dc.metricsResolution)); + metricsCarrierEdit->setText(QString::fromStdString(dc.metricsCarrier)); + metricsAppVersionEdit->setText(QString::fromStdString(dc.metricsAppVersion)); + sbsJsonEdit->setText(QString::fromStdString(dc.sbsJson)); + manualSessionCheck->setChecked(dc.manualSession); + disableSBSCheck->setChecked(dc.disableSBSUpdates); + alwaysPostCheck->setChecked(dc.alwaysPost); + enableRemoteConfigCheck->setChecked(dc.enableRemoteConfig); + disableAutoEventsOnUPCheck->setChecked(dc.disableAutoEventsOnUP); + logMsg("[App] Dev config loaded."); + } + + void onInitSDK() { + if (sdkInitialized) { + logMsg("[App] SDK is already initialized."); + return; + } + + std::string serverUrl = serverUrlEdit->text().trimmed().toStdString(); + std::string appKey = appKeyEdit->text().trimmed().toStdString(); + std::string deviceId = deviceIdEdit->text().trimmed().toStdString(); + std::string dbPath = dbPathEdit->text().trimmed().toStdString(); + std::string sbsJson = sbsJsonEdit->toPlainText().trimmed().toStdString(); + bool manualSession = manualSessionCheck->isChecked(); + bool disableSBS = disableSBSCheck->isChecked(); + bool usePost = alwaysPostCheck->isChecked(); + + if (serverUrl.empty() || appKey.empty()) { + logMsg("[App] Server URL and App Key are required."); + return; + } + + logMsg("[App] Initializing SDK..."); + logMsg("[App] Server URL: " + serverUrl); + logMsg("[App] App Key: " + appKey); + logMsg("[App] Device ID: " + deviceId); + + try { + auto &countly = Countly::getInstance(); + + // Set logger to forward to GUI (thread-safe via logMsg) + countly.setLogger([](LogLevel level, const std::string &message) { + std::string prefix; + switch (level) { + case LogLevel::DEBUG: prefix = "[DEBUG] "; break; + case LogLevel::INFO: prefix = "[INFO] "; break; + case LogLevel::WARNING: prefix = "[WARN] "; break; + case LogLevel::ERROR: prefix = "[ERROR] "; break; + case LogLevel::FATAL: prefix = "[FATAL] "; break; + } + std::string fullMsg = prefix + message; + std::cout << fullMsg << std::endl; + if (sInstance) { + sInstance->logMsg(fullMsg); + } + }); + + if (!deviceId.empty()) { + countly.setDeviceID(deviceId); + } + + countly.SetPath(dbPath); + + if (manualSession) { + countly.enableManualSessionControl(); + logMsg("[App] Manual session control: enabled"); + } + + if (disableSBS) { + countly.disableSDKBehaviorSettingsUpdates(); + logMsg("[App] SBS updates: disabled"); + } + + if (!sbsJson.empty()) { + countly.setSDKBehaviorSettings(sbsJson); + logMsg("[App] SBS JSON provided: " + sbsJson); + } + + if (usePost) { + countly.alwaysUsePost(true); + logMsg("[App] Always POST: enabled"); + } + + if (enableRemoteConfigCheck->isChecked()) { + countly.enableRemoteConfig(); + logMsg("[App] Remote config: enabled"); + } + + if (disableAutoEventsOnUPCheck->isChecked()) { + countly.disableAutoEventsOnUserProperties(); + logMsg("[App] Auto events on user properties: disabled"); + } + + // Salt + std::string salt = saltEdit->text().trimmed().toStdString(); + if (!salt.empty()) { + countly.setSalt(salt); + logMsg("[App] Salt: " + salt); + } + + // EQ threshold + std::string eqStr = eqThresholdEdit->text().trimmed().toStdString(); + if (!eqStr.empty()) { + int eqVal = std::stoi(eqStr); + countly.setEventsToRQThreshold(eqVal); + logMsg("[App] EQ threshold: " + eqStr); + } + + // RQ max size + std::string rqStr = rqMaxSizeEdit->text().trimmed().toStdString(); + if (!rqStr.empty()) { + unsigned int rqVal = std::stoul(rqStr); + countly.setMaxRequestQueueSize(rqVal); + logMsg("[App] RQ max size: " + rqStr); + } + + // RQ batch size + std::string batchStr = rqBatchSizeEdit->text().trimmed().toStdString(); + if (!batchStr.empty()) { + unsigned int batchVal = std::stoul(batchStr); + countly.setMaxRQProcessingBatchSize(batchVal); + logMsg("[App] RQ batch size: " + batchStr); + } + + // Session update interval + std::string suiStr = sessionIntervalEdit->text().trimmed().toStdString(); + if (!suiStr.empty()) { + unsigned short suiVal = static_cast(std::stoul(suiStr)); + countly.setAutomaticSessionUpdateInterval(suiVal); + logMsg("[App] Session update interval: " + suiStr + "s"); + } + + // Metrics + countly.setMetrics( + metricsOsEdit->text().trimmed().toStdString(), + metricsOsVersionEdit->text().trimmed().toStdString(), + metricsDeviceEdit->text().trimmed().toStdString(), + metricsResolutionEdit->text().trimmed().toStdString(), + metricsCarrierEdit->text().trimmed().toStdString(), + metricsAppVersionEdit->text().trimmed().toStdString() + ); + + // Port + int port = 0; + std::string portStr = portEdit->text().trimmed().toStdString(); + if (!portStr.empty()) { + port = std::stoi(portStr); + logMsg("[App] Port: " + portStr); + } + + countly.start(appKey, serverUrl, port, true); + + // Update loop interval (post-init is OK for this one) + std::string uiStr = updateIntervalEdit->text().trimmed().toStdString(); + if (!uiStr.empty()) { + size_t uiVal = std::stoul(uiStr); + countly.setUpdateInterval(uiVal); + logMsg("[App] Update loop interval: " + uiStr + "ms"); + } + + sdkInitialized = true; + initBtn->setEnabled(false); + stopBtn->setEnabled(true); + sdkStatusLabel->setText("Status: Initialized"); + sdkStatusLabel->setStyleSheet( + "font-weight: bold; color: #27ae60; font-size: 14px; padding: 8px 0;"); + logMsg("[App] SDK initialized successfully."); + + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] SDK init failed: ") + e.what()); + } + } + + void onStopSDK() { + if (!sdkInitialized) { + logMsg("[App] SDK is not initialized."); + return; + } + + logMsg("[App] Stopping SDK..."); + try { + Countly::getInstance().stop(); + sdkInitialized = false; + initBtn->setEnabled(true); + stopBtn->setEnabled(false); + sdkStatusLabel->setText("Status: Stopped"); + sdkStatusLabel->setStyleSheet( + "font-weight: bold; color: #c0392b; font-size: 14px; padding: 8px 0;"); + activeViews.clear(); + refreshActiveViewsList(); + logMsg("[App] SDK stopped."); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] SDK stop failed: ") + e.what()); + } + } + + void onBeginSession() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + logMsg("[App] Beginning session..."); + try { + bool result = Countly::getInstance().beginSession(); + sessionStatusLabel->setText( + result ? "Session: active" : "Session: begin failed"); + sessionStatusLabel->setStyleSheet( + result + ? "font-weight: bold; color: #27ae60; font-size: 14px; padding: 8px 0;" + : "font-weight: bold; color: #c0392b; font-size: 14px; padding: 8px 0;"); + logMsg("[App] beginSession() returned " + + std::string(result ? "true" : "false")); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onUpdateSession() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + logMsg("[App] Updating session..."); + try { + bool result = Countly::getInstance().updateSession(); + logMsg("[App] updateSession() returned " + + std::string(result ? "true" : "false")); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onEndSession() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + logMsg("[App] Ending session..."); + try { + bool result = Countly::getInstance().endSession(); + sessionStatusLabel->setText("Session: ended"); + sessionStatusLabel->setStyleSheet( + "font-weight: bold; color: #888; font-size: 14px; padding: 8px 0;"); + logMsg("[App] endSession() returned " + + std::string(result ? "true" : "false")); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void recordSingleEvent() { + std::string key = eventKeyEdit->text().trimmed().toStdString(); + int count = eventCountSpin->value(); + std::string sumStr = eventSumEdit->text().trimmed().toStdString(); + std::string segStr = eventSegEdit->text().trimmed().toStdString(); + + if (key.empty()) { logMsg("[App] Event key is required."); return; } + + try { + auto &countly = Countly::getInstance(); + auto segMap = parseSegmentation(segStr); + + if (!sumStr.empty()) { + double sum = std::stod(sumStr); + cly::Event event(key, count, sum); + for (const auto &kv : segMap) { + event.addSegmentation(kv.first, kv.second); + } + countly.addEvent(event); + logMsg("[App] Recorded event: " + key + + " (count=" + std::to_string(count) + + ", sum=" + sumStr + ")"); + } else { + cly::Event event(key, count); + for (const auto &kv : segMap) { + event.addSegmentation(kv.first, kv.second); + } + countly.addEvent(event); + logMsg("[App] Recorded event: " + key + + " (count=" + std::to_string(count) + ")"); + } + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onRecordEvent() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + recordSingleEvent(); + } + + void onRecord10Events() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + logMsg("[App] Recording 10 events..."); + for (int i = 0; i < 10; ++i) { + recordSingleEvent(); + } + logMsg("[App] 10 events recorded."); + } + + void onFlushEvents() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + logMsg("[App] Flushing events..."); + try { + Countly::getInstance().flushEvents(); + logMsg("[App] Events flushed."); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onOpenView() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + std::string name = viewNameEdit->text().trimmed().toStdString(); + std::string segStr = viewSegEdit->text().trimmed().toStdString(); + + if (name.empty()) { logMsg("[App] View name is required."); return; } + + try { + auto segMap = parseSegmentation(segStr); + std::string viewId = + Countly::getInstance().views().openView(name, segMap); + activeViews[viewId] = name; + refreshActiveViewsList(); + logMsg("[App] Opened view: \"" + name + "\" (id: " + viewId + ")"); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onCloseViewByName() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + std::string name = viewNameEdit->text().trimmed().toStdString(); + if (name.empty()) { logMsg("[App] View name is required."); return; } + + try { + Countly::getInstance().views().closeViewWithName(name); + // Remove from local tracking + for (auto it = activeViews.begin(); it != activeViews.end(); ) { + if (it->second == name) { + it = activeViews.erase(it); + } else { + ++it; + } + } + refreshActiveViewsList(); + logMsg("[App] Closed view by name: \"" + name + "\""); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onCloseSelectedView() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + auto *item = activeViewsList->currentItem(); + if (!item) { logMsg("[App] No view selected."); return; } + + std::string viewId = item->data(Qt::UserRole).toString().toStdString(); + try { + Countly::getInstance().views().closeViewWithID(viewId); + activeViews.erase(viewId); + refreshActiveViewsList(); + logMsg("[App] Closed view by ID: " + viewId); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void refreshActiveViewsList() { + activeViewsList->clear(); + for (const auto &kv : activeViews) { + auto *item = new QListWidgetItem( + QString::fromStdString(kv.second) + + " [" + QString::fromStdString(kv.first) + "]"); + item->setData(Qt::UserRole, QString::fromStdString(kv.first)); + activeViewsList->addItem(item); + } + } + + void onAddBreadcrumb() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + std::string bc = breadcrumbEdit->text().trimmed().toStdString(); + if (bc.empty()) { logMsg("[App] Breadcrumb text is required."); return; } + + try { + Countly::getInstance().crash().addBreadcrumb(bc); + logMsg("[App] Added breadcrumb: \"" + bc + "\""); + breadcrumbEdit->clear(); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onRecordException() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + + std::string title = crashTitleEdit->text().trimmed().toStdString(); + std::string stackTrace = + stackTraceEdit->toPlainText().trimmed().toStdString(); + bool fatal = fatalCheck->isChecked(); + std::string os = crashOsEdit->text().trimmed().toStdString(); + std::string segStr = crashSegEdit->text().trimmed().toStdString(); + + if (title.empty()) { logMsg("[App] Crash title is required."); return; } + + try { + std::map crashMetrics; + crashMetrics["_os"] = os.empty() ? "macOS" : os; + crashMetrics["_app_version"] = "1.0"; + crashMetrics["_error"] = title; + + auto segMap = parseSegmentation(segStr); + + Countly::getInstance().crash().recordException( + title, stackTrace, fatal, crashMetrics, segMap); + logMsg("[App] Recorded exception: \"" + title + + "\" (fatal=" + (fatal ? "true" : "false") + ")"); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onSetUserDetails() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + + std::map details; + auto addIfNotEmpty = [&](const std::string &key, QLineEdit *edit) { + std::string val = edit->text().trimmed().toStdString(); + if (!val.empty()) details[key] = val; + }; + + addIfNotEmpty("name", userNameEdit); + addIfNotEmpty("username", userUsernameEdit); + addIfNotEmpty("email", userEmailEdit); + addIfNotEmpty("phone", userPhoneEdit); + addIfNotEmpty("organization", userOrgEdit); + addIfNotEmpty("picture", userPictureEdit); + addIfNotEmpty("gender", userGenderEdit); + addIfNotEmpty("byear", userBirthYearEdit); + + if (details.empty()) { + logMsg("[App] No user details to set."); + return; + } + + try { + Countly::getInstance().setUserDetails(details); + logMsg("[App] User details set (" + + std::to_string(details.size()) + " properties)."); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onSetCustomUserDetails() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + + std::map customDetails; + for (int i = 0; i < customPropsList->count(); ++i) { + QString text = customPropsList->item(i)->text(); + int eqIdx = text.indexOf(" = "); + if (eqIdx > 0) { + QString key = text.left(eqIdx); + QString val = text.mid(eqIdx + 3); + customDetails[key.toStdString()] = val.toStdString(); + } + } + + if (customDetails.empty()) { + logMsg("[App] No custom properties to set."); + return; + } + + try { + Countly::getInstance().setCustomUserDetails(customDetails); + logMsg("[App] Custom user details set (" + + std::to_string(customDetails.size()) + " properties)."); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onSetLocation() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + + std::string cc = countryCodeEdit->text().trimmed().toStdString(); + std::string city = cityEdit->text().trimmed().toStdString(); + std::string gps = gpsEdit->text().trimmed().toStdString(); + std::string ip = ipEdit->text().trimmed().toStdString(); + + try { + Countly::getInstance().setLocation(cc, city, gps, ip); + logMsg("[App] Location set - Country: " + cc + + ", City: " + city + + ", GPS: " + gps + + ", IP: " + ip); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + void onFetchFeedbackWidgets() { + std::string serverUrl = serverUrlEdit->text().trimmed().toStdString(); + std::string appKey = appKeyEdit->text().trimmed().toStdString(); + std::string deviceId = deviceIdEdit->text().trimmed().toStdString(); + + if (serverUrl.empty() || appKey.empty() || deviceId.empty()) { + logMsg("[Widgets] Server URL, App Key, and Device ID are required (see Init tab)."); + return; + } + + // Clear existing cards (keep header at index 0 and stretch at the end). + while (fwCardsLayout->count() > 2) { + QLayoutItem *item = fwCardsLayout->takeAt(1); + if (item) { + if (item->widget()) item->widget()->deleteLater(); + delete item; + } + } + + logMsg("[Widgets] Fetching widgets from " + serverUrl + " ..."); + std::vector widgets; + try { + widgets = fwFetchWidgets(serverUrl, appKey, deviceId); + } catch (const std::exception &e) { + logMsg(std::string("[Widgets][Error] ") + e.what()); + return; + } + + fwHeaderLabel->setText(QString("Widgets (%1)").arg(widgets.size())); + logMsg("[Widgets] Found " + std::to_string(widgets.size()) + " widgets"); + + int insertAt = 1; // after header + for (const auto &w : widgets) { + auto *card = new WidgetCard(w); + connect(card, &WidgetCard::clicked, this, &MainWindow::onFeedbackWidgetSelected); + fwCardsLayout->insertWidget(insertAt++, card); + } + } + + void onFeedbackWidgetSelected(const CountlyFeedbackWidget &widget) { + std::string serverUrl = serverUrlEdit->text().trimmed().toStdString(); + std::string appKey = appKeyEdit->text().trimmed().toStdString(); + std::string deviceId = deviceIdEdit->text().trimmed().toStdString(); + + std::string url = fwConstructWebViewUrl(widget, serverUrl, appKey, deviceId); + logMsg("[Widgets] Present " + widget.type + " - \"" + widget.name + "\""); + logMsg("[Widgets] URL: " + url); + + fwWebPage->currentWidget = widget; + fwWebView->setVisible(false); + fwRightStack->setCurrentIndex(1); + fwLoadTimer->start(); + fwWebView->setUrl(QUrl(QString::fromStdString(url))); + } + + void onChangeDeviceId() { + if (!sdkInitialized) { logMsg("[App] SDK not initialized."); return; } + + std::string newId = newDeviceIdEdit->text().trimmed().toStdString(); + bool merge = mergeCheck->isChecked(); + + if (newId.empty()) { + logMsg("[App] New device ID is required."); + return; + } + + try { + Countly::getInstance().setDeviceID(newId, merge); + logMsg("[App] Device ID changed to: \"" + newId + + "\" (merge=" + (merge ? "true" : "false") + ")"); + } catch (const std::exception &e) { + logMsg(std::string("[App][Error] ") + e.what()); + } + } + + // Static instance pointer for the logger callback + static MainWindow *sInstance; + friend int main(int argc, char *argv[]); +}; + +MainWindow *MainWindow::sInstance = nullptr; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +int main(int argc, char *argv[]) { + QApplication app(argc, argv); + app.setStyleSheet(GLOBAL_STYLE); + + MainWindow window; + MainWindow::sInstance = &window; + + // Connect the thread-safe log signal + QObject::connect(&window, &MainWindow::logMessageReceived, + &window, &MainWindow::appendLog, + Qt::QueuedConnection); + + window.show(); + int result = app.exec(); + + // Stop SDK and clear the instance pointer BEFORE window is destroyed, + // so background threads don't call logMsg on a dead MainWindow. + MainWindow::sInstance = nullptr; + Countly::getInstance().stop(); + + return result; +} + +#include "main.moc" From de02020b4f9c3b2dd803757c328073b686199b6c Mon Sep 17 00:00:00 2001 From: turtledreams Date: Mon, 1 Jun 2026 22:49:35 +0900 Subject: [PATCH 2/5] cmake min version update --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ae71747..a40ce9f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.0) +cmake_minimum_required(VERSION 3.0...3.31) # Acquire countly version from constant file(READ ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/constants.hpp COUNTLY_HPP_CONTENTS) From 6cc4ce80d6c52bb4fc1fe553a5caa1fdc57f2ee5 Mon Sep 17 00:00:00 2001 From: turtledreams Date: Wed, 3 Jun 2026 19:48:49 +0900 Subject: [PATCH 3/5] update changelog --- CHANGELOG.md | 3 +++ include/countly/constants.hpp | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c32cefe..7d0e9a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 26.1.1 +- Updated CMake minimum required version to use the range format `3.0...3.31` to suppress modern CMake policy warnings. + ## 26.1.0 - ! Minor breaking change ! SDK Behavior Settings is now enabled by default. Changes made on SDK Manager > SDK Behavior Settings on your server will affect SDK behavior directly. diff --git a/include/countly/constants.hpp b/include/countly/constants.hpp index 9626997..68490d4 100644 --- a/include/countly/constants.hpp +++ b/include/countly/constants.hpp @@ -13,7 +13,7 @@ #include #define COUNTLY_SDK_NAME "cpp-native-unknown" -#define COUNTLY_SDK_VERSION "26.1.0" +#define COUNTLY_SDK_VERSION "26.1.1" #define COUNTLY_POST_THRESHOLD 2000 #define COUNTLY_KEEPALIVE_INTERVAL 3000 #define COUNTLY_MAX_EVENTS_DEFAULT 200 From 96cdd565c071036f94190e2445ac64293fa41958 Mon Sep 17 00:00:00 2001 From: turtledreams Date: Wed, 3 Jun 2026 19:50:43 +0900 Subject: [PATCH 4/5] ch --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0e9a4..77a6ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ ## 26.1.1 -- Updated CMake minimum required version to use the range format `3.0...3.31` to suppress modern CMake policy warnings. +- Updated CMake minimum required version to use the range format with upper the end of `3.31`. ## 26.1.0 - ! Minor breaking change ! SDK Behavior Settings is now enabled by default. Changes made on SDK Manager > SDK Behavior Settings on your server will affect SDK behavior directly. From dbdabc176c3b578cbe9c2cb3ae9a6ae564c348f8 Mon Sep 17 00:00:00 2001 From: turtledreams Date: Thu, 4 Jun 2026 19:17:59 +0900 Subject: [PATCH 5/5] mutex fix --- CHANGELOG.md | 1 + CMakeLists.txt | 1 + include/countly.hpp | 10 ++ src/countly.cpp | 268 ++++++++++++++++--------------- src/crash_module.cpp | 10 +- src/request_module.cpp | 120 +++++++------- tests/mutex_exception_safety.cpp | 128 +++++++++++++++ 7 files changed, 344 insertions(+), 194 deletions(-) create mode 100644 tests/mutex_exception_safety.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a6ed0..457f853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## 26.1.1 - Updated CMake minimum required version to use the range format with upper the end of `3.31`. +- Hardened mutex handling against exceptions. ## 26.1.0 - ! Minor breaking change ! SDK Behavior Settings is now enabled by default. Changes made on SDK Manager > SDK Behavior Settings on your server will affect SDK behavior directly. diff --git a/CMakeLists.txt b/CMakeLists.txt index a40ce9f..d1d1786 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,6 +119,7 @@ if(COUNTLY_BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/tests/request.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/config.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/immediate_stop.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/mutex_exception_safety.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/sbs.cpp) target_compile_options(countly-tests PRIVATE -g) diff --git a/include/countly.hpp b/include/countly.hpp index 64de2b7..281bafc 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -313,6 +313,14 @@ class Countly : public cly::CountlyDelegates { */ std::vector debugReturnStateOfEQ(); + /** + * Injects a raw (possibly malformed) string directly into the in-memory event + * queue, bypassing serialization. Used only by the mutex exception-safety test + * to force a parse failure inside the locked section of updateSession(). + * Warning: This method is for debugging purposes, and it is going to be removed in the future. + */ + void debugInjectRawEvent(const std::string &raw); + /** * This function should not be used as it will be removed in a future release. * It is currently added as a temporary workaround. @@ -331,6 +339,8 @@ class Countly : public cly::CountlyDelegates { inline void clearRequestQueue() { if (is_sdk_initialized) { + // serialize storage access with the background processQueue thread + std::lock_guard lk(*mutex); requestModule->clearRequestQueue(); } } diff --git a/src/countly.cpp b/src/countly.cpp index c7cb4ef..849ecbb 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -45,11 +45,12 @@ Countly &Countly::getInstance() { } #ifdef COUNTLY_BUILD_TESTS -void Countly::halt() { - if (_sharedInstance) { - _sharedInstance->stop(); // joins threads, releases mutex normally - } - _sharedInstance.reset(new Countly()); } +void Countly::halt() { + if (_sharedInstance) { + _sharedInstance->stop(); + } + _sharedInstance.reset(new Countly()); +} #endif /** @@ -62,9 +63,8 @@ void Countly::setMaxRequestQueueSize(unsigned int requestQueueSize) { return; } - mutex->lock(); + std::lock_guard lk(*mutex); configuration->requestQueueThreshold = requestQueueSize; - mutex->unlock(); } /** @@ -73,9 +73,8 @@ void Countly::setMaxRequestQueueSize(unsigned int requestQueueSize) { * @param batchSize: max size of requests to process at a time */ void Countly::setMaxRQProcessingBatchSize(unsigned int batchSize) { - mutex->lock(); + std::lock_guard lk(*mutex); configuration->maxProcessingBatchSize = batchSize; - mutex->unlock(); } void Countly::alwaysUsePost(bool value) { @@ -84,9 +83,8 @@ void Countly::alwaysUsePost(bool value) { return; } - mutex->lock(); + std::lock_guard lk(*mutex); configuration->forcePost = value; - mutex->unlock(); } void Countly::setSalt(const std::string &value) { @@ -95,9 +93,8 @@ void Countly::setSalt(const std::string &value) { return; } - mutex->lock(); + std::lock_guard lk(*mutex); configuration->salt = value; - mutex->unlock(); } void Countly::setLogger(void (*fun)(LogLevel level, const std::string &message)) { @@ -106,9 +103,8 @@ void Countly::setLogger(void (*fun)(LogLevel level, const std::string &message)) return; } - mutex->lock(); + std::lock_guard lk(*mutex); logger->setLogger(fun); - mutex->unlock(); } void Countly::setHTTPClient(HTTPClientFunction fun) { @@ -117,9 +113,8 @@ void Countly::setHTTPClient(HTTPClientFunction fun) { return; } - mutex->lock(); + std::lock_guard lk(*mutex); configuration->http_client_function = fun; - mutex->unlock(); } void Countly::setSha256(SHA256Function fun) { @@ -128,9 +123,8 @@ void Countly::setSha256(SHA256Function fun) { return; } - mutex->lock(); + std::lock_guard lk(*mutex); configuration->sha256_function = fun; - mutex->unlock(); } /** @@ -142,9 +136,8 @@ void Countly::enableManualSessionControl() { return; } - mutex->lock(); + std::lock_guard lk(*mutex); configuration->manualSessionControl = true; - mutex->unlock(); } /** @@ -156,9 +149,8 @@ void Countly::disableAutoEventsOnUserProperties() { return; } - mutex->lock(); + std::lock_guard lk(*mutex); configuration->autoEventsOnUserProperties = false; - mutex->unlock(); } void Countly::enableImmediateRequestOnStop() { @@ -202,29 +194,31 @@ void Countly::setMetrics(const std::string &os, const std::string &os_version, c } void Countly::setUserDetails(const std::map &value) { - mutex->lock(); + // unique_lock so the mutex is released on scope exit, including on a throwing + // json/map/addRequestToQueue op; unlock/relock around the self-locking flushEvents(). + std::unique_lock lk(*mutex); session_params["user_details"] = value; if (!is_sdk_initialized) { log(LogLevel::ERROR, "[Countly] setUserDetails, This method can't be called before SDK initialization. Returning."); - mutex->unlock(); return; } if (configuration->autoEventsOnUserProperties == true) { - mutex->unlock(); + lk.unlock(); flushEvents(); - mutex->lock(); + lk.lock(); } std::map data = {{"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}, {"user_details", session_params["user_details"].dump()}}; requestModule->addRequestToQueue(data); - mutex->unlock(); } void Countly::setCustomUserDetails(const std::map &value) { - mutex->lock(); + // unique_lock so the mutex is released on scope exit, including on a throwing + // json/map/addRequestToQueue op; unlock/re-acquire around the self-locking flushEvents(). + std::unique_lock lk(*mutex); // Apply user property filter if (configurationModule) { @@ -245,7 +239,6 @@ void Countly::setCustomUserDetails(const std::map &val if (filteredValue.empty()) { log(LogLevel::DEBUG, "[Countly] setCustomUserDetails, All user properties were filtered out by SBS user property filter."); - mutex->unlock(); return; } session_params["user_details"]["custom"] = filteredValue; @@ -258,20 +251,17 @@ void Countly::setCustomUserDetails(const std::map &val if (!is_sdk_initialized) { log(LogLevel::ERROR, "[Countly] setCustomUserDetails, This method can't be called before SDK initialization. Returning."); - mutex->unlock(); return; } if (configuration->autoEventsOnUserProperties == true) { - mutex->unlock(); + lk.unlock(); flushEvents(); - mutex->lock(); + lk.lock(); } std::map data = {{"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}, {"user_details", session_params["user_details"].dump()}}; requestModule->addRequestToQueue(data); - - mutex->unlock(); } #pragma region User location @@ -300,10 +290,11 @@ void Countly::setLocation(const std::string &countryCode, const std::string &cit return; } bool isClearingLocation = countryCode.empty() && city.empty() && gpsCoordinates.empty() && ipAddress.empty(); - mutex->lock(); + // unique_lock so the mutex is released on scope exit (incl. throwing json writes); + // explicit unlock before the self-locking _sendIndependantLocationRequest() below. + std::unique_lock lk(*mutex); if (!isClearingLocation && configurationModule->isLocationTrackingEnabled() == false) { log(LogLevel::ERROR, "[Countly] setLocation, Location tracking is disabled in server configuration, can not set location."); - mutex->unlock(); return; } log(LogLevel::INFO, "[Countly] setLocation, Setting location: countryCode = [" + countryCode + "], city = [" + city + "], gpsCoordinates = [" + gpsCoordinates + "], ipAddress = [" + ipAddress + "]"); @@ -317,7 +308,7 @@ void Countly::setLocation(const std::string &countryCode, const std::string &cit session_params["location"] = gpsCoordinates; session_params["country_code"] = countryCode; - mutex->unlock(); + lk.unlock(); if (is_sdk_initialized) { _sendIndependantLocationRequest(); @@ -325,7 +316,7 @@ void Countly::setLocation(const std::string &countryCode, const std::string &cit } void Countly::_sendIndependantLocationRequest() { - mutex->lock(); + std::lock_guard lk(*mutex); log(LogLevel::DEBUG, "[Countly] _sendIndependantLocationRequest, Start"); /* @@ -363,32 +354,30 @@ void Countly::_sendIndependantLocationRequest() { } requestModule->addRequestToQueue(data); - - mutex->unlock(); } #pragma endregion User location #pragma region Device Id void Countly::setDeviceID(const std::string &value, bool same_user) { - mutex->lock(); + // unique_lock so the mutex is released on scope exit (incl. throwing json writes); + // explicit unlock before the self-locking _changeDeviceId* helpers below. + std::unique_lock lk(*mutex); log(LogLevel::INFO, "[Countly] setDeviceID, Device ID change requested, new value = [" + value + "]"); if (!session_params.contains("device_id")) { session_params["device_id"] = value; configuration->deviceId = value; log(LogLevel::DEBUG, "[Countly] setDeviceID, No previous device id, assigning initial device id"); - mutex->unlock(); return; } if (session_params["device_id"].get() == value) { log(LogLevel::DEBUG, "[Countly] setDeviceID, New device id equals existing device id, ignoring."); - mutex->unlock(); return; } - mutex->unlock(); + lk.unlock(); if (!is_sdk_initialized) { log(LogLevel::ERROR, "[Countly] setDeviceID, Device id can't be changed while the SDK has not been initialized."); return; @@ -403,7 +392,7 @@ void Countly::setDeviceID(const std::string &value, bool same_user) { /* Change device ID with merge after SDK has been initialized.*/ void Countly::_changeDeviceIdWithMerge(const std::string &value) { - mutex->lock(); + std::lock_guard lk(*mutex); log(LogLevel::DEBUG, "[Countly] _changeDeviceIdWithMerge, deviceId = [" + value + "]"); session_params["old_device_id"] = session_params["device_id"]; @@ -421,7 +410,6 @@ void Countly::_changeDeviceIdWithMerge(const std::string &value) { requestModule->addRequestToQueue(data); session_params.erase("old_device_id"); - mutex->unlock(); } void Countly::_changeDeviceIdWithoutMerge(const std::string &value) { @@ -433,10 +421,11 @@ void Countly::_changeDeviceIdWithoutMerge(const std::string &value) { endSession(); } - mutex->lock(); - session_params["device_id"] = value; - configuration->deviceId = value; - mutex->unlock(); + { + std::lock_guard lk(*mutex); + session_params["device_id"] = value; + configuration->deviceId = value; + } // start a new session for new user if (configuration->manualSessionControl == false) { @@ -446,17 +435,18 @@ void Countly::_changeDeviceIdWithoutMerge(const std::string &value) { #pragma endregion Device Id void Countly::start(const std::string &app_key, const std::string &host, int port, bool start_thread) { - mutex->lock(); + // unique_lock so the mutex is released on scope exit (incl. allocation/json + // throws while constructing modules); unlock/re-acquire around the self-locking + // configurationModule->fetch*/beginSession() calls below. + std::unique_lock lk(*mutex); if (is_sdk_initialized) { log(LogLevel::ERROR, "[Countly] start, SDK has already been initialized, 'start' should not be called a second time!"); - mutex->unlock(); return; } #ifdef COUNTLY_USE_SQLITE if (configuration->databasePath == "" || configuration->databasePath == " ") { log(LogLevel::ERROR, "[Countly] start, Database path can not be empty or blank."); - mutex->unlock(); return; } #endif @@ -529,24 +519,23 @@ void Countly::start(const std::string &app_key, const std::string &host, int por is_sdk_initialized = result; // after this point SDK is initialized. if (!is_sdk_initialized) { log(LogLevel::ERROR, "[Countly] start, SDK initialization failed."); - mutex->unlock(); return; } if (is_sdk_initialized) { - mutex->unlock(); + lk.unlock(); configurationModule->fetchConfigFromStorage(); configurationModule->fetchConfigFromServer(session_params); configurationModule->startServerConfigUpdateTimer(session_params); - mutex->lock(); + lk.lock(); } if (!running) { if (configuration->manualSessionControl == false) { - mutex->unlock(); + lk.unlock(); beginSession(); - mutex->lock(); + lk.lock(); } if (start_thread) { @@ -561,7 +550,6 @@ void Countly::start(const std::string &app_key, const std::string &host, int por } } } - mutex->unlock(); } /** @@ -678,13 +666,13 @@ void Countly::addEvent(const cly::Event &event) { } } - mutex->lock(); + std::unique_lock lk(*mutex); #ifndef COUNTLY_USE_SQLITE event_queue.push_back(filteredEvent.serialize()); #else addEventToSqlite(filteredEvent); #endif - mutex->unlock(); + lk.unlock(); checkAndSendEventToRQ(); } @@ -695,16 +683,19 @@ void Countly::checkAndSendEventToRQ() { if (queueSize < 0) { return; } - mutex->lock(); + // unique_lock so the mutex is released on scope exit, including when json::parse + // on a queued event or sendEventsToRQ throws; unlock/re-acquire around the + // self-locking fillEventsIntoJson(). + std::unique_lock lk(*mutex); #ifdef COUNTLY_USE_SQLITE if (queueSize >= configurationModule->getEventQueueSizeLimit()) { log(LogLevel::DEBUG, "[Countly] checkAndSendEventToRQ, Event queue threshold is reached"); std::string event_ids; // fetch events up to the threshold from the database - mutex->unlock(); + lk.unlock(); fillEventsIntoJson(events, event_ids); - mutex->lock(); + lk.lock(); // send them to request queue sendEventsToRQ(events); // remove them from database @@ -720,7 +711,6 @@ void Countly::checkAndSendEventToRQ() { event_queue.clear(); } #endif - mutex->unlock(); } void Countly::setMaxEvents(size_t value) { @@ -730,7 +720,7 @@ void Countly::setMaxEvents(size_t value) { void Countly::setEventsToRQThreshold(int value) { log(LogLevel::DEBUG, "[Countly] setEventsToRQThreshold, Given threshold:[" + std::to_string(value) + "]"); - mutex->lock(); + std::unique_lock lk(*mutex); if (value < 1) { log(LogLevel::WARNING, "[Countly] setEventsToRQThreshold, Threshold can not be less than 1. Setting it to 1 instead of:[" + std::to_string(value) + "]"); value = 1; @@ -742,7 +732,7 @@ void Countly::setEventsToRQThreshold(int value) { // set the value configuration->eventQueueThreshold = value; // if current queue size is greater than the new threshold, send events to RQ - mutex->unlock(); + lk.unlock(); checkAndSendEventToRQ(); } @@ -786,12 +776,12 @@ void Countly::flushEvents(std::chrono::seconds timeout) { bool Countly::attemptSessionUpdateEQ() { // return false if event queue is empty #ifndef COUNTLY_USE_SQLITE - mutex->lock(); - if (event_queue.empty()) { - mutex->unlock(); - return false; + { + std::lock_guard lk(*mutex); + if (event_queue.empty()) { + return false; + } } - mutex->unlock(); #else int event_count = checkEQSize(); if (event_count <= 0) { @@ -808,6 +798,8 @@ bool Countly::attemptSessionUpdateEQ() { void Countly::clearEQInternal() { #ifndef COUNTLY_USE_SQLITE + // event_queue is guarded by mutex; the only caller (flushEvents) holds no lock here. + std::lock_guard lk(*mutex); event_queue.clear(); #else clearPersistentEQ(); @@ -849,6 +841,7 @@ std::vector Countly::debugReturnStateOfEQ() { } sqlite3_close(database); #else + std::lock_guard lk(*mutex); std::vector v(event_queue.begin(), event_queue.end()); #endif return v; @@ -858,6 +851,16 @@ std::vector Countly::debugReturnStateOfEQ() { log(LogLevel::FATAL, log_message.str()); } } + +void Countly::debugInjectRawEvent(const std::string &raw) { + std::lock_guard lk(*mutex); +#ifndef COUNTLY_USE_SQLITE + event_queue.push_back(raw); +#else + (void)raw; + log(LogLevel::WARNING, "[Countly] debugInjectRawEvent, not supported in SQLite builds."); +#endif +} #endif bool Countly::beginSession() { @@ -865,15 +868,16 @@ bool Countly::beginSession() { log(LogLevel::WARNING, "[Countly] beginSession, SDK is not initialized."); return false; } - mutex->lock(); + // unique_lock so the mutex is always released on scope exit, including the + // early returns below and any exception (e.g. a json type_error from a + // session_params access, or addRequestToQueue) thrown while it is held. + std::unique_lock lk(*mutex); log(LogLevel::INFO, "[Countly] beginSession, Starting session"); if (configurationModule->isSessionTrackingEnabled() == false) { log(LogLevel::ERROR, "[Countly] beginSession, Session tracking is disabled in server configuration, can not begin session."); - mutex->unlock(); return false; } if (began_session == true) { - mutex->unlock(); log(LogLevel::DEBUG, "[Countly] beginSession, Session is already active."); return true; } @@ -913,12 +917,14 @@ bool Countly::beginSession() { session_params.erase("user_details"); last_sent_session_request = Countly::getTimestamp(); began_session = true; - mutex->unlock(); + // snapshot guarded state before releasing the lock + bool shouldUpdateRemoteConfig = remote_config_enabled; + lk.unlock(); - if (remote_config_enabled) { + if (shouldUpdateRemoteConfig) { updateRemoteConfig(); } - return began_session; + return true; } /** @@ -929,16 +935,19 @@ bool Countly::updateSession() { log(LogLevel::WARNING, "[Countly] updateSession, SDK is not initialized."); return false; } + // unique_lock so the mutex is always released on scope exit, including when an + // exception propagates out of a call made while the lock is held. + std::unique_lock lk(*mutex, std::defer_lock); try { // Check if there was a session, if not try to start one - mutex->lock(); + lk.lock(); if (configurationModule->isSessionTrackingEnabled() == false) { log(LogLevel::ERROR, "[Countly] updateSession, Session tracking is disabled in server configuration, can not update session."); - mutex->unlock(); + lk.unlock(); return false; } if (began_session == false) { - mutex->unlock(); + lk.unlock(); if (configuration->manualSessionControl == true) { log(LogLevel::WARNING, "[Countly] updateSession, SDK is in manual session control mode and there is no active session. Please start a session first."); return false; @@ -948,16 +957,16 @@ bool Countly::updateSession() { // if beginSession fails, we should not try to update session return false; } - mutex->lock(); + lk.lock(); began_session = true; } // events array nlohmann::json events = nlohmann::json::array(); std::string event_ids; - mutex->unlock(); + lk.unlock(); bool no_events = checkEQSize() > 0 ? false : true; - mutex->lock(); + lk.lock(); if (!no_events) { #ifndef COUNTLY_USE_SQLITE @@ -966,16 +975,16 @@ bool Countly::updateSession() { } #else // TODO: If database_path was empty there was return false here - mutex->unlock(); + lk.unlock(); fillEventsIntoJson(events, event_ids); - mutex->lock(); + lk.lock(); #endif } else { log(LogLevel::DEBUG, "[Countly] updateSession, EQ empty."); } - mutex->unlock(); + lk.unlock(); auto duration = std::chrono::duration_cast(getSessionDuration()); - mutex->lock(); + lk.lock(); // report session duration if it is greater than the configured session duration value if (duration.count() >= configurationModule->getSessionUpdateInterval()) { @@ -1006,19 +1015,22 @@ bool Countly::updateSession() { log_message << "[Countly] updateSession, error: " << e.what(); log(LogLevel::FATAL, log_message.str()); } - mutex->unlock(); + // lk releases the mutex on scope exit if it is still held. return true; } void Countly::packEvents() { + // unique_lock so the mutex is always released on scope exit, including when an + // exception propagates out of a call made while the lock is held. + std::unique_lock lk(*mutex, std::defer_lock); try { - mutex->lock(); + lk.lock(); // events array nlohmann::json events = nlohmann::json::array(); std::string event_ids; - mutex->unlock(); + lk.unlock(); bool no_events = checkEQSize() > 0 ? false : true; - mutex->lock(); + lk.lock(); if (!no_events) { #ifndef COUNTLY_USE_SQLITE @@ -1027,9 +1039,9 @@ void Countly::packEvents() { } #else // TODO: If database_path was empty there was return false here - mutex->unlock(); + lk.unlock(); fillEventsIntoJson(events, event_ids); - mutex->lock(); + lk.lock(); #endif } else { log(LogLevel::DEBUG, "[Countly] packEvents, EQ empty."); @@ -1054,7 +1066,7 @@ void Countly::packEvents() { log_message << "[Countly] packEvents, error: " << e.what(); log(LogLevel::FATAL, log_message.str()); } - mutex->unlock(); + // lk releases the mutex on scope exit if it is still held. } void Countly::sendEventsToRQ(const nlohmann::json &events) { @@ -1081,12 +1093,14 @@ bool Countly::endSession() { const auto timestamp = std::chrono::duration_cast(now.time_since_epoch()); const auto duration = std::chrono::duration_cast(getSessionDuration(now)); - mutex->lock(); + // lock_guard so the mutex is released on scope exit, including the early + // return below and any exception (e.g. a json type_error from a session_params + // access, or addRequestToQueue) thrown while it is held. + std::lock_guard lk(*mutex); std::map data = {{"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}, {"session_duration", std::to_string(duration.count())}, {"timestamp", std::to_string(timestamp.count())}, {"end_session", "1"}}; if (is_being_disposed) { // if SDK is being destroyed, don't attempt to send the end-session request. - mutex->unlock(); return false; } @@ -1094,7 +1108,6 @@ bool Countly::endSession() { last_sent_session_request = now; began_session = false; - mutex->unlock(); return true; } @@ -1124,7 +1137,11 @@ int Countly::checkRQSize() { return request_count; } - request_count = static_cast(requestModule->RQSize()); + { + // serialize storage access with processQueue/addRequestToQueue + std::lock_guard lk(*mutex); + request_count = static_cast(requestModule->RQSize()); + } return request_count; } @@ -1132,9 +1149,8 @@ int Countly::checkRQSize() { int Countly::checkMemoryEQSize() { log(LogLevel::DEBUG, "[Countly] checkMemoryEQSize, Checking event queue size in memory."); int result = 0; - mutex->lock(); + std::lock_guard lk(*mutex); result = static_cast(event_queue.size()); - mutex->unlock(); return result; } #endif @@ -1169,9 +1185,11 @@ void Countly::removeEventWithId(const std::string &event_ids) { } void Countly::fillEventsIntoJson(nlohmann::json &events, std::string &event_ids) { - mutex->lock(); + // lock_guard so the mutex is released on every exit path, including the early + // return below and any exception (e.g. nlohmann::json::parse on a corrupt + // stored row at the loop below) thrown while it is held. + std::lock_guard lk(*mutex); if (database_path.empty()) { - mutex->unlock(); log(LogLevel::FATAL, "[Countly] fillEventsIntoJson, SQLite database path is not set."); event_ids = ""; return; @@ -1220,21 +1238,19 @@ void Countly::fillEventsIntoJson(nlohmann::json &events, std::string &event_ids) log(LogLevel::ERROR, "[Countly] fillEventsIntoJson, Could not open database."); } sqlite3_close(database); - mutex->unlock(); } int Countly::checkPersistentEQSize() { int result = -1; - mutex->lock(); + std::unique_lock lk(*mutex); if (database_path.empty()) { - mutex->unlock(); log(LogLevel::FATAL, "[Countly] checkPersistentEQSize, SQLite database path is not set"); return result; } sqlite3 *database; int return_value = sqlite3_open(database_path.c_str(), &database); - mutex->unlock(); + lk.unlock(); if (return_value == SQLITE_OK) { char *error_message; @@ -1391,9 +1407,8 @@ std::string Countly::calculateChecksum(const std::string &salt, const std::strin } std::chrono::system_clock::duration Countly::getSessionDuration(std::chrono::system_clock::time_point now) { - mutex->lock(); + std::lock_guard lk(*mutex); std::chrono::system_clock::duration duration = now - last_sent_session_request; - mutex->unlock(); return duration; } @@ -1449,26 +1464,17 @@ void Countly::updateLoop() { running = false; } } catch (const std::exception &e) { - bool acquired = mutex->try_lock(); running = false; log(LogLevel::ERROR, std::string("[Countly][updateLoop] exception in update loop: ") + e.what()); - if (acquired) { - mutex->unlock(); - } } catch (...) { - bool acquired = mutex->try_lock(); running = false; log(LogLevel::FATAL, "[Countly][updateLoop] unknown non-std::exception caught, stopping update loop"); - if (acquired) { - mutex->unlock(); - } } } void Countly::enableRemoteConfig() { - mutex->lock(); + std::lock_guard lk(*mutex); remote_config_enabled = true; - mutex->unlock(); } void Countly::_fetchRemoteConfig(const std::map &data) { @@ -1478,11 +1484,10 @@ void Countly::_fetchRemoteConfig(const std::map &data) } HTTPResponse response = requestModule->sendHTTP("/o/sdk", requestBuilder->serializeData(data)); - mutex->lock(); + std::lock_guard lk(*mutex); if (response.success) { remote_config = response.data; } - mutex->unlock(); } void Countly::updateRemoteConfig() { @@ -1490,16 +1495,15 @@ void Countly::updateRemoteConfig() { log(LogLevel::WARNING, "[Countly] updateRemoteConfig, SDK is not initialized."); return; } - mutex->lock(); + std::unique_lock lk(*mutex); if (!session_params["app_key"].is_string() || !session_params["device_id"].is_string()) { log(LogLevel::ERROR, "[Countly] updateRemoteConfig, Error updating remote config, app key or device id is missing"); - mutex->unlock(); return; } std::map data = {{"method", "fetch_remote_config"}, {"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}}; - mutex->unlock(); + lk.unlock(); // Fetch remote config asynchronously std::thread _thread(&Countly::_fetchRemoteConfig, this, data); @@ -1507,9 +1511,8 @@ void Countly::updateRemoteConfig() { } nlohmann::json Countly::getRemoteConfigValue(const std::string &key) { - mutex->lock(); + std::lock_guard lk(*mutex); nlohmann::json value = remote_config[key]; - mutex->unlock(); return value; } @@ -1520,13 +1523,12 @@ void Countly::_updateRemoteConfigWithSpecificValues(const std::mapsendHTTP("/o/sdk", requestBuilder->serializeData(data)); - mutex->lock(); + std::lock_guard lk(*mutex); if (response.success) { for (auto it = response.data.begin(); it != response.data.end(); ++it) { remote_config[it.key()] = it.value(); } } - mutex->unlock(); } void Countly::updateRemoteConfigFor(std::string *keys, size_t key_count) { @@ -1534,7 +1536,7 @@ void Countly::updateRemoteConfigFor(std::string *keys, size_t key_count) { log(LogLevel::WARNING, "[Countly] updateRemoteConfigFor, SDK is not initialized."); return; } - mutex->lock(); + std::unique_lock lk(*mutex); std::map data = {{"method", "fetch_remote_config"}, {"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}}; { @@ -1544,7 +1546,7 @@ void Countly::updateRemoteConfigFor(std::string *keys, size_t key_count) { } data["keys"] = keys_json.dump(); } - mutex->unlock(); + lk.unlock(); // Fetch remote config asynchronously std::thread _thread(&Countly::_updateRemoteConfigWithSpecificValues, this, data); @@ -1556,7 +1558,7 @@ void Countly::updateRemoteConfigExcept(std::string *keys, size_t key_count) { log(LogLevel::WARNING, "[Countly] updateRemoteConfigExcept, SDK is not initialized."); return; } - mutex->lock(); + std::unique_lock lk(*mutex); std::map data = {{"method", "fetch_remote_config"}, {"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}}; { @@ -1566,7 +1568,7 @@ void Countly::updateRemoteConfigExcept(std::string *keys, size_t key_count) { } data["omit_keys"] = keys_json.dump(); } - mutex->unlock(); + lk.unlock(); // Fetch remote config asynchronously std::thread _thread(&Countly::_updateRemoteConfigWithSpecificValues, this, data); diff --git a/src/crash_module.cpp b/src/crash_module.cpp index 649231e..f44e6bb 100644 --- a/src/crash_module.cpp +++ b/src/crash_module.cpp @@ -36,14 +36,13 @@ CrashModule::CrashModule(std::shared_ptr config, std::shar void CrashModule::addBreadcrumb(const std::string &value) { impl->_logger->log(LogLevel::INFO, "[Countly] [CrashModule] addBreadcrumb, value = [" + value + "]"); - impl->_mutex->lock(); + std::lock_guard lk(*impl->_mutex); // if breadcrumb threshold is reached, remove oldest breadcrumb if (impl->_breadCrumbs.size() >= impl->_configuration->breadcrumbsThreshold) { impl->_breadCrumbs.pop_front(); } // add new breadcrumb impl->_breadCrumbs.push_back(value); - impl->_mutex->unlock(); } // function to record exception @@ -81,8 +80,9 @@ void CrashModule::recordException(const std::string &title, const std::string &s impl->_logger->log(LogLevel::ERROR, "[Countly] [CrashModule] recordException, The crash metric '_app_version' can't be empty"); } - // lock mutex to avoid concurrent access - impl->_mutex->lock(); + // lock mutex to avoid concurrent access; lock_guard releases on scope exit, + // including when json construction / crash.dump() / addRequestToQueue throws. + std::lock_guard lk(*impl->_mutex); // convert breadcrumbs vector to a string and add to json object std::ostringstream outstream; std::copy(impl->_breadCrumbs.begin(), impl->_breadCrumbs.end(), std::ostream_iterator(outstream, "\n")); @@ -101,8 +101,6 @@ void CrashModule::recordException(const std::string &title, const std::string &s // create a map with the crash json object as value and "crash" as key, and add the map to the request queue std::map data = {{"crash", crash.dump()}}; impl->_requestModule->addRequestToQueue(data); - // unlock mutex - impl->_mutex->unlock(); } void CrashModule::setConfigurationProvider(std::weak_ptr provider) { impl->_configProvider = std::move(provider); } diff --git a/src/request_module.cpp b/src/request_module.cpp index 210c87d..d6983d0 100644 --- a/src/request_module.cpp +++ b/src/request_module.cpp @@ -122,79 +122,89 @@ void RequestModule::addRequestToQueue(const std::map & void RequestModule::clearRequestQueue() { impl->_storageModule->RQClearAll(); } void RequestModule::processQueue(std::shared_ptr mutex) { - mutex->lock(); - - if (std::shared_ptr config = _configProvider.lock()) { - if (config->isTrackingEnabled() == false) { - impl->_logger->log(LogLevel::DEBUG, "[Countly] [RequestModule] processQueue: Tracking is disabled. Not processing request queue."); - mutex->unlock(); + { + // lock_guard so the mutex is released on every exit path of this block, + // including early returns and any exception thrown while it is held. + std::lock_guard lk(*mutex); + + if (std::shared_ptr config = _configProvider.lock()) { + if (config->isTrackingEnabled() == false) { + impl->_logger->log(LogLevel::DEBUG, "[Countly] [RequestModule] processQueue: Tracking is disabled. Not processing request queue."); + return; + } + if (config->isNetworkingEnabled() == false) { + impl->_logger->log(LogLevel::DEBUG, "[Countly] [RequestModule] processQueue: Networking is disabled. Not processing request queue."); + return; + } + } else { + impl->_logger->log(LogLevel::WARNING, "[Countly] [RequestModule] processQueue: ConfigurationProvider unavailable, skipping queue processing."); return; } - if (config->isNetworkingEnabled() == false) { - impl->_logger->log(LogLevel::DEBUG, "[Countly] [RequestModule] processQueue: Networking is disabled. Not processing request queue."); - mutex->unlock(); + + // making sure that no other thread is processing the queue + if (impl->is_queue_being_processed) { return; } - } else { - impl->_logger->log(LogLevel::WARNING, "[Countly] [RequestModule] processQueue: ConfigurationProvider unavailable, skipping queue processing."); - mutex->unlock(); - return; - } - // making sure that no other thread is processing the queue - if (impl->is_queue_being_processed) { - mutex->unlock(); - return; + // if this is the only thread, mark that processing is happening + impl->is_queue_being_processed = true; } - // if this is the only thread, mark that processing is happening - impl->is_queue_being_processed = true; - mutex->unlock(); - // this counter is used to make sure that we don't get stuck in an infinite/long loop of request processing int processedRequestsCounter = 0; - while (true) { - mutex->lock(); - impl->_logger->log(LogLevel::DEBUG, cly::utils::format_string("[Countly] [RequestModule] processQueue: Processing the request queue.")); - if (impl->_storageModule->RQCount() == 0) { - impl->_logger->log(LogLevel::DEBUG, cly::utils::format_string("[Countly] [RequestModule] processQueue: Queue is empty.")); - - // stop sending requests once the queue is empty - mutex->unlock(); - break; - } - - std::shared_ptr data = impl->_storageModule->RQPeekFront(); - mutex->unlock(); - HTTPResponse response = sendHTTP("/i", data->getData()); + // From here on the processing flag is set, so it MUST be cleared on every exit + // path. If an exception propagates out (e.g. from sendHTTP or a storage op), + // reset the flag before rethrowing, otherwise the queue would be permanently + // blocked (every later call would early-return at the is_queue_being_processed + // guard above) for the rest of the SDK's lifetime. + try { + while (true) { + std::shared_ptr data; + { + std::lock_guard lk(*mutex); + impl->_logger->log(LogLevel::DEBUG, cly::utils::format_string("[Countly] [RequestModule] processQueue: Processing the request queue.")); + if (impl->_storageModule->RQCount() == 0) { + impl->_logger->log(LogLevel::DEBUG, cly::utils::format_string("[Countly] [RequestModule] processQueue: Queue is empty.")); + + // stop sending requests once the queue is empty + break; + } - mutex->lock(); - if (!response.success) { - impl->_logger->log(LogLevel::DEBUG, cly::utils::format_string("[Countly] [RequestModule] processQueue: Failed to deliver to server, will try again later.")); - // if the request was not a success, abort sending and try again in the future - mutex->unlock(); - break; - } + data = impl->_storageModule->RQPeekFront(); + } + HTTPResponse response = sendHTTP("/i", data->getData()); + + { + std::lock_guard lk(*mutex); + if (!response.success) { + impl->_logger->log(LogLevel::DEBUG, cly::utils::format_string("[Countly] [RequestModule] processQueue: Failed to deliver to server, will try again later.")); + // if the request was not a success, abort sending and try again in the future + break; + } - // we pop the front only if it is still the same request - // the queue might have changed while we were sending the request - impl->_storageModule->RQRemoveFront(data); - processedRequestsCounter++; + // we pop the front only if it is still the same request + // the queue might have changed while we were sending the request + impl->_storageModule->RQRemoveFront(data); + processedRequestsCounter++; - if (processedRequestsCounter > impl->_configuration->maxProcessingBatchSize) { - impl->_logger->log(LogLevel::DEBUG, cly::utils::format_string("[Countly] [RequestModule] processQueue: Batch limit has been reached, will do next batch later.")); - mutex->unlock(); - break; + if (processedRequestsCounter > impl->_configuration->maxProcessingBatchSize) { + impl->_logger->log(LogLevel::DEBUG, cly::utils::format_string("[Countly] [RequestModule] processQueue: Batch limit has been reached, will do next batch later.")); + break; + } + } } - - mutex->unlock(); + } catch (...) { + std::lock_guard lk(*mutex); + // mark that no thread is processing the request queue, then let the + // exception continue propagating to the update loop's handler. + impl->is_queue_being_processed = false; + throw; } - mutex->lock(); + std::lock_guard lk(*mutex); // mark that no thread is processing the request queue impl->is_queue_being_processed = false; - mutex->unlock(); } HTTPResponse RequestModule::sendHTTP(std::string path, std::string data) { diff --git a/tests/mutex_exception_safety.cpp b/tests/mutex_exception_safety.cpp new file mode 100644 index 0000000..e1b4e88 --- /dev/null +++ b/tests/mutex_exception_safety.cpp @@ -0,0 +1,128 @@ +#include "countly.hpp" +#include "doctest.h" +#include "nlohmann/json.hpp" +#include "test_utils.hpp" +#include +#include +#include + +using namespace cly; +using namespace test_utils; + +/** + * Regression tests for mutex exception-safety in the background update loop. + * + * Before the RAII conversion, an exception thrown WHILE the shared mutex was + * held (e.g. nlohmann::json::parse on a malformed event inside updateSession) + * skipped the trailing mutex->unlock(): the background thread returned still + * owning the mutex, so the subsequent stop() -> _deleteThread() blocked forever + * on its own lock_guard -> shutdown deadlock. + * + * These tests assert that stop() stays responsive after an exception is raised + * inside the running loop. stop() is wrapped in std::async + wait_for so a + * regression manifests as a clean test failure within the timeout instead of + * hanging the whole test binary (which would also wedge the next test's + * clearSDK()/halt()). + */ + +// The malformed-event vector exercises the non-SQLite (in-memory) event queue +// path in updateSession(); the parse-while-locked site (json::parse over the +// in-memory queue) is compiled out under COUNTLY_USE_SQLITE. +#ifndef COUNTLY_USE_SQLITE +TEST_CASE("mutex exception safety - malformed event in updateSession does not deadlock stop()") { + clearSDK(); + Countly &ct = Countly::getInstance(); + ct.setHTTPClient(fakeSendHTTP); + ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); + ct.SetPath(TEST_DATABASE_NAME); + // Short interval so the threaded loop reaches updateSession() quickly. + ct.setUpdateInterval(50); + http_call_queue.clear(); + + // start_thread=true => automatic session + background updateLoop running. + ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); + // Let the loop run a couple of cycles so the session has begun. + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + + // Inject a string that is NOT valid JSON. On the next cycle, updateSession() + // iterates the event queue and calls nlohmann::json::parse on it WHILE holding + // the mutex, which throws. With RAII the unique_lock releases on unwind; the + // exception reaches updateLoop's catch, the loop ends, and the thread returns + // WITHOUT owning the mutex. + ct.debugInjectRawEvent("{not valid json"); + + // Give the loop time to pick up the bad event and throw. + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + // stop() must return. Run it on another thread so a deadlock fails the test + // within the timeout rather than hanging the suite. + auto fut = std::async(std::launch::async, [&]() { ct.stop(); }); + bool returned = fut.wait_for(std::chrono::seconds(5)) == std::future_status::ready; + REQUIRE(returned); + fut.get(); +} +#endif + +// Exercises a USER-THREAD leak path: checkAndSendEventToRQ() parses the in-memory +// event queue while holding the mutex. A malformed entry makes nlohmann::json::parse +// throw there. Pre-fix the bare lock was leaked; post-fix the unique_lock releases on +// unwind. We assert a SUBSEQUENT locking call still returns (i.e. the mutex was freed), +// which would hang forever if the lock had leaked. +#ifndef COUNTLY_USE_SQLITE +TEST_CASE("mutex exception safety - throwing user-API path does not leak the mutex") { + clearSDK(); + Countly &ct = Countly::getInstance(); + ct.setHTTPClient(fakeSendHTTP); + ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); + ct.SetPath(TEST_DATABASE_NAME); + ct.setEventsToRQThreshold(1); // low threshold so addEvent triggers checkAndSendEventToRQ + http_call_queue.clear(); + // start_thread=false: pure user-thread test, no background loop involved. + ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + + // Seed a malformed event, then add a valid one. addEvent -> checkAndSendEventToRQ + // iterates the queue (size >= threshold) and json::parse throws while the mutex is held. + ct.debugInjectRawEvent("{not valid json"); + bool threw = false; + try { + cly::Event ev("trigger", 1); + ct.addEvent(ev); + } catch (...) { + threw = true; // exception propagating out is expected; the point is the mutex is released + } + + // If the mutex had leaked, this locking call would block forever. + auto fut = std::async(std::launch::async, [&]() { return ct.checkEQSize(); }); + bool returned = fut.wait_for(std::chrono::seconds(5)) == std::future_status::ready; + REQUIRE(returned); + fut.get(); + (void)threw; +} +#endif + +// This vector works in every build: a throwing HTTP client raises an exception +// inside the running loop (from sendHTTP / processQueue). It does not reproduce +// the original lock-leak deadlock (sendHTTP runs with the mutex released), but +// it guards the end-to-end invariant that an exception in the loop never wedges +// shutdown, and that the request-queue processing flag is cleared on unwind so +// processing can resume. +TEST_CASE("mutex exception safety - throwing HTTP client keeps stop() responsive") { + clearSDK(); + Countly &ct = Countly::getInstance(); + ct.setHTTPClient([](bool, const std::string &, const std::string &) -> HTTPResponse { + throw std::runtime_error("simulated HTTP failure"); + }); + ct.setDeviceID(COUNTLY_TEST_DEVICE_ID); + ct.SetPath(TEST_DATABASE_NAME); + ct.setUpdateInterval(50); + http_call_queue.clear(); + + ct.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, true); + // Let at least one loop cycle reach sendHTTP and throw. + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + auto fut = std::async(std::launch::async, [&]() { ct.stop(); }); + bool returned = fut.wait_for(std::chrono::seconds(5)) == std::future_status::ready; + REQUIRE(returned); + fut.get(); +}