From 7cfc1fe5d5961340fb397edc5e91a0320232214e Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:09:23 +0900 Subject: [PATCH] Consent feature --- CHANGELOG.md | 6 + CMakeLists.txt | 5 +- include/countly.hpp | 150 +++ include/countly/configuration_module.hpp | 5 + include/countly/consent_module.hpp | 129 +++ include/countly/constants.hpp | 5 + include/countly/crash_module.hpp | 12 + include/countly/views_module.hpp | 13 + src/configuration_module.cpp | 22 +- src/consent_module.cpp | 165 ++++ src/countly.cpp | 430 ++++++++- src/crash_module.cpp | 26 + src/views_module.cpp | 50 + tests/consent.cpp | 1127 ++++++++++++++++++++++ 14 files changed, 2122 insertions(+), 23 deletions(-) create mode 100644 include/countly/consent_module.hpp create mode 100644 src/consent_module.cpp create mode 100644 tests/consent.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 596c59f..ec32e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ ## XX.XX.XX +- ! Minor breaking change ! The "Consent required" option of the SDK Behavior Settings now applies. While it is on, the SDK collects nothing until the app gives consent; while it is off, the SDK works as if every consent were given, also in apps that require consent. + +- Added user consent. With `setRequiresConsent(true)` a feature works only after its consent is given, at start with `setConsentEnabled` or later with `giveConsent`, `giveConsentAll` or `setConsent`, and stops when the consent is removed with `removeConsent`, `removeConsentAll` or `setConsent`. The SDK sends every change to the server. The feature names are in `cly::consent`, and `cly::consent::allFeatures()` lists them all. `createFeatureGroup` and `setConsentFeatureGroup` handle groups of features; `getConsent` and `checkAllConsent` show the current state. +- While consent is required, changing the device ID without merge removes all consent, so the app gives it again for the new user. - Added the health check feature. The SDK now sends a health check request once after `start()`. Like any request, it updates the user's last seen time on the server. - Added the SDK name and version (`sdk_name` and `sdk_version`) to every request, including the remote config and SDK Behavior Settings fetches. Before, only the begin session request carried them. - Added `multiplyCustomUserDetail` for multiplying the numeric value of a custom user property on the server. @@ -7,6 +11,8 @@ - Calls made before `start()`, a second `start()`, beginning a session that already runs and ending one that does not are now logged at warning level. Failed and non-JSON server answers, and session calls ignored because the SDK Behavior Settings turned session tracking off, are now logged at debug level. - Mitigated an issue where reaching the event queue threshold logged a warning that events were dropped, although they were sent. +- Mitigated an issue where a view that was open during a device ID change without merge was ended under the new device ID. +- Mitigated an issue where session starts let the server work out the user's location from the IP address while the SDK Behavior Settings turned location tracking off. ## 26.8.1 - ! Minor breaking change ! Builds now stop with a clear error when the SDK headers find a nlohmann/json older than 3.11 first on the include path, instead of mixing two versions of the library. diff --git a/CMakeLists.txt b/CMakeLists.txt index 622bd4b..9f87040 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,7 @@ message("Build Sample programs:" ${COUNTLY_BUILD_SAMPLE}) set(COUNTLY_PUBLIC_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/configuration_module.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/configuration_provider.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/consent_module.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/constants.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/countly_configuration.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/countly/crash_module.hpp @@ -57,6 +58,7 @@ add_library(countly ${CMAKE_CURRENT_SOURCE_DIR}/src/storage_module_memory.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/event.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/configuration_module.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/consent_module.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/health_check_module.cpp) add_library(countly::countly ALIAS countly) @@ -156,7 +158,8 @@ if(COUNTLY_BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/tests/internal_limits_integration.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/multi_instance.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/user_details.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/tests/health_check.cpp) + ${CMAKE_CURRENT_SOURCE_DIR}/tests/health_check.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/consent.cpp) target_compile_options(countly-tests PRIVATE $<$>:-g>) target_compile_definitions(countly-tests PRIVATE COUNTLY_BUILD_TESTS) diff --git a/include/countly.hpp b/include/countly.hpp index 4d73167..3133c6d 100644 --- a/include/countly.hpp +++ b/include/countly.hpp @@ -23,6 +23,7 @@ #ifdef _WIN32 #undef ERROR #endif +#include "countly/consent_module.hpp" #include "countly/event.hpp" #include "countly/health_check_module.hpp" #include "countly/logger_module.hpp" @@ -398,6 +399,78 @@ class Countly : public cly::CountlyDelegates { configuration->sdkBehaviorSettings = settings_json; } + /** + * Requires the user's consent before the SDK collects anything. Each feature then works only after its consent + * is given, at start with setConsentEnabled() or later with giveConsent(). The SDK does not remember consent + * across restarts: give it again at every start. While the server's SDK behavior settings contain "Consent + * required", the server's value applies instead. Call it before start(). + */ + void setRequiresConsent(bool required); + + /** + * Gives consent at start to the listed features (see cly::consent), and to no other feature. Has an effect only + * while consent is required. Names this SDK does not offer are ignored with a warning. Call it before start(). + */ + void setConsentEnabled(const std::vector &featureNames); + + /** + * Gives consent to the listed features (see cly::consent) or feature groups. The features start at once, for + * example a session begins with automatic session tracking, and the SDK queues the new consent state for the + * server. Ignored while consent is not required. Call it after start(). + */ + void giveConsent(const std::vector &featureNames); + + /** + * Removes consent from the listed features (see cly::consent) or feature groups. The features stop at once: + * running views and the session end, and breadcrumbs, user details waiting for the first session and the + * location are cleared, the location also on the server. Data recorded before stays queued and is sent. The SDK + * queues the new consent state for the server. Ignored while consent is not required. Call it after start(). + */ + void removeConsent(const std::vector &featureNames); + + /** + * Gives consent to every feature this SDK offers, like giveConsent(). + */ + void giveConsentAll(); + + /** + * Removes consent from every feature this SDK offers, like removeConsent(). + */ + void removeConsentAll(); + + /** + * Whether the feature, or every feature of the group, has consent. Always true while consent is not required. + */ + bool getConsent(const std::string &featureName); + + /** + * Gives or removes consent for the listed features or feature groups, like giveConsent() and removeConsent(). + */ + void setConsent(const std::vector &featureNames, bool isConsentGiven); + + /** + * Stores a named group of features, so that their consent can be given or removed at once with + * setConsentFeatureGroup(), giveConsent() or removeConsent(). Creating a group again replaces it. A group can + * not take the name of a feature. + */ + void createFeatureGroup(const std::string &groupName, const std::vector &featureNames); + + /** + * Gives or removes consent for every feature of the group. + */ + void setConsentFeatureGroup(const std::string &groupName, bool isConsentGiven); + + /** + * Logs whether consent is required and whether each feature has consent. Changes nothing. + */ + void checkAllConsent(); + + /** + * Applies a change of the server's "Consent required" setting: turning the requirement on acts like removing + * consent from every feature that has none, lifting it like giving consent to every feature. + */ + void ConsentRequirementChanged() override; + #ifdef COUNTLY_BUILD_TESTS /** * Convert event queue into list. @@ -522,6 +595,8 @@ class Countly : public cly::CountlyDelegates { /** * Updates the session, counted in the health check as a call of the app or, with 'automatic', of the SDK. * A call of the app in manual session control without a running session is reported as log code w760. + * Without the "sessions" consent a call of the app is ignored, while the SDK's own call still moves the queued + * events into a request. */ bool updateSessionInternal(bool automatic); @@ -544,6 +619,73 @@ class Countly : public cly::CountlyDelegates { * Stores the health check counters when a session update interval passed since they were last stored. */ void saveHealthCheckOnTimer(); + + /** + * Where a consent change comes from: a call of the app, the silent removal of a device ID change without merge, + * or the server's "Consent required" setting. + */ + enum class ConsentChangeSource { App, DeviceIdChangedWithoutMerge, ServerRequirement }; + + /** + * Whether the feature has consent; always true while consent is not required. + */ + bool hasConsent(const char *feature) const; + + /** + * Whether an event with this key may be recorded: a custom event needs the "events" consent, an internal event + * the consent of its own feature, and a "[CLY]_" key that is no known internal event the "events" consent. + */ + bool hasEventConsent(const std::string &key) const; + + /** + * Whether location is on: the "location" consent is given and the SDK behavior settings allow location tracking. + * While it is off, a session start carries the empty location, which keeps the server from working out the + * location from the IP address. + */ + bool isLocationOn(); + + /** + * The shared part of the public calls that give or remove consent: checks the call, expands groups, records w402 + * for unknown names and runs the change for the features whose consent changes. + */ + void changeConsent(const std::vector &names, bool given, const char *caller); + + /** + * Changes the consent of the features: the reactions that need the old consent run first, then the flags change, + * the consent state is queued for the server, and the reactions to the new state run. The silent removal of a + * device ID change without merge queues no consent state and no location erase. The caller holds + * consent_change_mutex and not the instance mutex. + */ + void runConsentChange(const std::vector &features, bool given, ConsentChangeSource source); + + /** + * The reactions that run while the features still have consent, before it is removed: the running views and the + * session end. + */ + void consentWillBeRemoved(const std::vector &features); + + /** + * The reactions to the new consent of the features. Removed: the breadcrumbs, the user details waiting for the + * first session and the stored location are cleared, and a location erase is queued unless the removal is silent. + * Given: with automatic sessions a session begins, and the automatic remote config download runs. + */ + void consentChanged(const std::vector &features, bool given, ConsentChangeSource source); + + /** + * Queues the consent state of every feature this SDK offers. + */ + void queueConsentState(); + + /** + * Clears the stored location values. + */ + void clearStoredLocation(); + + /** + * Applies the server's stored "Consent required" setting, and while consent is required, queues the consent + * state and, when location is off and no session start will be sent, a location erase. Called at the end of start(). + */ + void activateConsent(); bool began_session = false; std::atomic is_being_disposed{false}; std::atomic is_sdk_initialized{false}; @@ -567,6 +709,14 @@ class Countly : public cly::CountlyDelegates { // Health check counters; created with the instance, so warnings logged before start() are counted too. std::shared_ptr health_check = std::make_shared(); + + // Consent state; created with the instance, so the consent options can be set before start(). + std::shared_ptr consent_module = std::make_shared(); + // Serializes consent changes, their reactions and device ID changes without merge. Taken before the instance mutex. + // The destructor takes it once, so that no server requirement change runs while it tears the modules down. + std::mutex consent_change_mutex; + // Set at the end of start(); before that, a change of the server's requirement only updates the state. + bool consent_active = false; std::atomic health_check_sent{false}; std::chrono::steady_clock::time_point last_health_check_save = std::chrono::steady_clock::now(); diff --git a/include/countly/configuration_module.hpp b/include/countly/configuration_module.hpp index ce1b401..ee8143d 100644 --- a/include/countly/configuration_module.hpp +++ b/include/countly/configuration_module.hpp @@ -40,6 +40,11 @@ class ConfigurationModule : public ConfigurationProvider { bool isCustomEventTrackingEnabled(); bool isCrashReportingEnabled() const override; + /** + * The server's "Consent required" setting: -1 while the SDK behavior settings don't contain it, otherwise 0 or 1. + */ + int getConsentRequired() const; + unsigned int getRequestQueueSizeLimit() const override; SDKLimits getLimits() const override; unsigned int getEventQueueSizeLimit(); diff --git a/include/countly/consent_module.hpp b/include/countly/consent_module.hpp new file mode 100644 index 0000000..ee355cc --- /dev/null +++ b/include/countly/consent_module.hpp @@ -0,0 +1,129 @@ +#ifndef CONSENT_MODULE_HPP_ +#define CONSENT_MODULE_HPP_ + +#include +#include +#include +#include + +#include "nlohmann/json.hpp" + +namespace cly { +/** + * The names of the features an app gives or removes consent for, as the server receives them. + */ +namespace consent { +constexpr const char *SESSIONS = "sessions"; +constexpr const char *EVENTS = "events"; +constexpr const char *VIEWS = "views"; +constexpr const char *LOCATION = "location"; +constexpr const char *CRASHES = "crashes"; +constexpr const char *USERS = "users"; +constexpr const char *REMOTE_CONFIG = "remote-config"; +constexpr const char *METRICS = "metrics"; +constexpr const char *FEEDBACK = "feedback"; +constexpr const char *STAR_RATING = "star-rating"; + +/** + * The names of every feature this SDK offers for consent, for example to build a consent screen + * or to give every consent at start with setConsentEnabled(cly::consent::allFeatures()). + */ +std::vector allFeatures(); +} // namespace consent + +/** + * The consent state of one SDK instance: whether consent is required, which features have consent + * and the feature groups the app created. Kept in memory only. Thread safe; it never calls out of + * itself while holding its lock. + */ +class ConsentModule { +public: + /** + * Whether the name is a feature this SDK offers for consent. + */ + static bool isFeature(const std::string &name); + + /** + * Sets whether the app requires consent. + */ + void setRequiredByApp(bool required); + + /** + * Sets the server's "cr" setting: -1 while the SDK behavior settings don't contain it, otherwise 0 or 1. + */ + void setRequiredByServer(int required); + + /** + * Whether the requirement would change if the server's "cr" setting became the given value. + */ + bool requirementChangesWith(int requiredByServer) const; + + /** + * Whether consent is required: the server's "cr" while the settings contain it, otherwise the app's choice. + */ + bool isRequired() const; + + /** + * Whether the feature, or every feature of the group, has consent. Always true while consent is + * not required; false for a name that is neither a feature nor a group. + */ + bool getConsent(const std::string &name) const; + + /** + * Whether the name is a feature this SDK offers or a group the app created. + */ + bool isKnownName(const std::string &name) const; + + /** + * Whether the name is a group the app created. + */ + bool isGroup(const std::string &name) const; + + /** + * Expands feature and group names into feature names, without duplicates and in the order given. + * Names that are neither are added to 'unknown'. + */ + std::vector expand(const std::vector &names, std::vector &unknown) const; + + /** + * The given features whose own consent flag differs from 'given', regardless of the requirement. + */ + std::vector featuresToChange(const std::vector &features, bool given) const; + + /** + * Sets the consent flag of the given features. + */ + void setGiven(const std::vector &features, bool given); + + /** + * Gives consent to exactly the given features and removes it from every other one. + */ + void setOnlyGiven(const std::vector &features); + + /** + * The features whose own consent flag is set, or not set when 'given' is false. + */ + std::vector featuresWithFlag(bool given) const; + + /** + * The consent flag of every feature this SDK offers, as the consent request sends it. + */ + nlohmann::json state() const; + + /** + * Stores a group of features under the name, replacing a group of that name. + */ + void createGroup(const std::string &name, const std::vector &features); + +private: + mutable std::mutex _mutex; + bool _requiredByApp = false; + int _requiredByServer = -1; + std::map _given; + std::map> _groups; + + bool isRequiredLocked() const; + bool getConsentLocked(const std::string &name) const; +}; +} // namespace cly +#endif diff --git a/include/countly/constants.hpp b/include/countly/constants.hpp index 261a350..fd1723e 100644 --- a/include/countly/constants.hpp +++ b/include/countly/constants.hpp @@ -163,6 +163,11 @@ class CountlyDelegates { virtual void RecordEvent(const std::string &key, const std::map &segmentation, int count, double sum, double duration) = 0; virtual void RecordLocation(const std::string &countryCode, const std::string &city, const std::string &gpsCoordinates, const std::string &ipAddress) = 0; + + /** + * Called by the SDK behavior settings when the server's "cr" setting changes whether consent is required. + */ + virtual void ConsentRequirementChanged() {} }; } // namespace cly diff --git a/include/countly/crash_module.hpp b/include/countly/crash_module.hpp index e2bbadc..d0f09a8 100644 --- a/include/countly/crash_module.hpp +++ b/include/countly/crash_module.hpp @@ -10,6 +10,8 @@ #include "countly/request_module.hpp" namespace cly { +class ConsentModule; + class CrashModule { public: @@ -39,6 +41,16 @@ class CrashModule { class CrashModuleImpl; std::unique_ptr impl; void setConfigurationProvider(std::weak_ptr provider); // try injecting + + /** + * Sets the consent state the crash calls check for the "crashes" consent. + */ + void setConsentModule(std::shared_ptr consent); + + /** + * Forgets every breadcrumb. + */ + void clearBreadcrumbs(); }; } // namespace cly #endif diff --git a/include/countly/views_module.hpp b/include/countly/views_module.hpp index 1f48006..090a76d 100644 --- a/include/countly/views_module.hpp +++ b/include/countly/views_module.hpp @@ -9,6 +9,8 @@ #include "countly/logger_module.hpp" namespace cly { +class ConsentModule; + class ViewsModule { public: @@ -44,6 +46,17 @@ class ViewsModule { class ViewModuleImpl; std::unique_ptr impl; void setConfigurationProvider(std::weak_ptr provider); // try injecting + + /** + * Sets the consent state the view calls check for the "views" consent. + */ + void setConsentModule(std::shared_ptr consent); + + /** + * Ends every running view and records its end event, while the SDK behavior settings allow view tracking; + * otherwise the views are dropped without an event. + */ + void stopAllViews(); }; } // namespace cly #endif diff --git a/src/configuration_module.cpp b/src/configuration_module.cpp index e0189cd..c0b04af 100644 --- a/src/configuration_module.cpp +++ b/src/configuration_module.cpp @@ -20,6 +20,7 @@ static constexpr const char *KEY_VIEW_TRACKING = "vt"; static constexpr const char *KEY_LOCATION_TRACKING = "lt"; static constexpr const char *KEY_CUSTOM_EVENT_TRACKING = "cet"; static constexpr const char *KEY_CRASH_REPORTING = "crt"; +static constexpr const char *KEY_CONSENT_REQUIRED = "cr"; static constexpr const char *KEY_SERVER_CONFIG_UPDATE_INTERVAL = "scui"; static constexpr const char *KEY_LOGGING = "log"; // not used and implemented yet @@ -34,7 +35,6 @@ static constexpr const char *KEY_SEGMENTATION_WHITELIST = "sw"; static constexpr const char *KEY_EVENT_SEGMENTATION_WHITELIST = "esw"; // sdk configuration - not implemented yet -static constexpr const char *KEY_CONSENT_REQUIRED = "cr"; static constexpr const char *KEY_DROP_OLD_REQUEST_TIME = "dort"; // sdk internal limits - not implemented yet @@ -80,6 +80,8 @@ class ConfigurationModule::ConfigurationModuleImpl { std::atomic locationTrackingEnabled{true}; std::atomic customEventTrackingEnabled{true}; std::atomic crashReportingEnabled{true}; + // -1 while the settings don't contain "cr", otherwise 0 or 1 + std::atomic consentRequired{-1}; std::atomic eventQueueThreshold{0}; std::atomic requestQueueSizeLimit{0}; std::atomic sessionUpdateInterval{0}; @@ -222,6 +224,10 @@ class ConfigurationModule::ConfigurationModuleImpl { configUpdateCv.notify_all(); } + if (changedSettings.contains(KEY_CONSENT_REQUIRED)) { + _cly->ConsentRequirementChanged(); + } + if (changedSettings.contains(KEY_LOCATION_TRACKING) && changedSettings[KEY_LOCATION_TRACKING] == false) { // disable location - safe because _onSBSChanged is called outside of _mutex _cly->RecordLocation("", "", "", ""); @@ -240,6 +246,15 @@ class ConfigurationModule::ConfigurationModuleImpl { bool locationTrackingCurrent = getBool(KEY_LOCATION_TRACKING, locationTrackingEnabledVal); unsigned int serverConfigUpdateIntervalCurrent = getUInt(KEY_SERVER_CONFIG_UPDATE_INTERVAL, serverConfigUpdateIntervalVal); + int consentRequiredVal = consentRequired.load(std::memory_order_acquire); + int consentRequiredCurrent = -1; + if (sdk_behavior_settings.is_object()) { + auto consentRequiredSetting = sdk_behavior_settings.find(KEY_CONSENT_REQUIRED); + if (consentRequiredSetting != sdk_behavior_settings.end() && consentRequiredSetting->is_boolean()) { + consentRequiredCurrent = consentRequiredSetting->get() ? 1 : 0; + } + } + consentRequired.store(consentRequiredCurrent, std::memory_order_release); trackingEnabled.store(getBool(KEY_TRACKING, trackingEnabledVal), std::memory_order_release); networkingEnabled.store(getBool(KEY_NETWORKING, networkingEnabledVal), std::memory_order_release); @@ -299,6 +314,9 @@ class ConfigurationModule::ConfigurationModuleImpl { if (serverConfigUpdateIntervalCurrent != serverConfigUpdateIntervalVal) { changedSettings[KEY_SERVER_CONFIG_UPDATE_INTERVAL] = serverConfigUpdateIntervalCurrent; } + if (consentRequiredCurrent != consentRequiredVal) { + changedSettings[KEY_CONSENT_REQUIRED] = consentRequiredCurrent; + } return changedSettings; } @@ -507,6 +525,8 @@ bool ConfigurationModule::isCustomEventTrackingEnabled() { return impl->customEv bool ConfigurationModule::isCrashReportingEnabled() const { return impl->crashReportingEnabled.load(std::memory_order_acquire); } +int ConfigurationModule::getConsentRequired() const { return impl->consentRequired.load(std::memory_order_acquire); } + unsigned int ConfigurationModule::getRequestQueueSizeLimit() const { return impl->requestQueueSizeLimit.load(std::memory_order_acquire); } SDKLimits ConfigurationModule::getLimits() const { diff --git a/src/consent_module.cpp b/src/consent_module.cpp new file mode 100644 index 0000000..fc7ebb3 --- /dev/null +++ b/src/consent_module.cpp @@ -0,0 +1,165 @@ +#include "countly/consent_module.hpp" + +#include +#include + +namespace cly { +namespace { +/** + * The features this SDK offers for consent. + */ +const char *const FEATURES[] = {consent::SESSIONS, consent::EVENTS, consent::VIEWS, consent::LOCATION, consent::CRASHES, consent::USERS, consent::REMOTE_CONFIG, consent::METRICS, consent::FEEDBACK, consent::STAR_RATING}; + +/** + * The consent flag of a feature, false when it was never set. + */ +bool flagOf(const std::map &given, const std::string &feature) { + std::map::const_iterator found = given.find(feature); + return found != given.end() && found->second; +} +} // namespace + +namespace consent { +std::vector allFeatures() { return std::vector(std::begin(FEATURES), std::end(FEATURES)); } +} // namespace consent + +bool ConsentModule::isFeature(const std::string &name) { + for (const char *feature : FEATURES) { + if (name == feature) { + return true; + } + } + return false; +} + +void ConsentModule::setRequiredByApp(bool required) { + std::lock_guard lock(_mutex); + _requiredByApp = required; +} + +void ConsentModule::setRequiredByServer(int required) { + std::lock_guard lock(_mutex); + _requiredByServer = required; +} + +bool ConsentModule::requirementChangesWith(int requiredByServer) const { + std::lock_guard lock(_mutex); + const bool requiredThen = requiredByServer < 0 ? _requiredByApp : requiredByServer == 1; + return isRequiredLocked() != requiredThen; +} + +bool ConsentModule::isRequired() const { + std::lock_guard lock(_mutex); + return isRequiredLocked(); +} + +bool ConsentModule::getConsent(const std::string &name) const { + std::lock_guard lock(_mutex); + return getConsentLocked(name); +} + +bool ConsentModule::isKnownName(const std::string &name) const { + std::lock_guard lock(_mutex); + return isFeature(name) || _groups.find(name) != _groups.end(); +} + +bool ConsentModule::isGroup(const std::string &name) const { + std::lock_guard lock(_mutex); + return _groups.find(name) != _groups.end(); +} + +std::vector ConsentModule::expand(const std::vector &names, std::vector &unknown) const { + std::lock_guard lock(_mutex); + std::vector features; + const auto add = [&features](const std::string &feature) { + if (std::find(features.begin(), features.end(), feature) == features.end()) { + features.push_back(feature); + } + }; + + for (const std::string &name : names) { + if (isFeature(name)) { + add(name); + continue; + } + std::map>::const_iterator group = _groups.find(name); + if (group == _groups.end()) { + unknown.push_back(name); + continue; + } + for (const std::string &feature : group->second) { + add(feature); + } + } + return features; +} + +std::vector ConsentModule::featuresToChange(const std::vector &features, bool given) const { + std::lock_guard lock(_mutex); + std::vector changing; + for (const std::string &feature : features) { + if (flagOf(_given, feature) != given) { + changing.push_back(feature); + } + } + return changing; +} + +void ConsentModule::setGiven(const std::vector &features, bool given) { + std::lock_guard lock(_mutex); + for (const std::string &feature : features) { + _given[feature] = given; + } +} + +void ConsentModule::setOnlyGiven(const std::vector &features) { + std::lock_guard lock(_mutex); + _given.clear(); + for (const std::string &feature : features) { + _given[feature] = true; + } +} + +std::vector ConsentModule::featuresWithFlag(bool given) const { + std::lock_guard lock(_mutex); + std::vector features; + for (const char *feature : FEATURES) { + if (flagOf(_given, feature) == given) { + features.push_back(feature); + } + } + return features; +} + +nlohmann::json ConsentModule::state() const { + std::lock_guard lock(_mutex); + nlohmann::json state = nlohmann::json::object(); + for (const char *feature : FEATURES) { + state[feature] = flagOf(_given, feature); + } + return state; +} + +void ConsentModule::createGroup(const std::string &name, const std::vector &features) { + std::lock_guard lock(_mutex); + _groups[name] = features; +} + +bool ConsentModule::isRequiredLocked() const { return _requiredByServer < 0 ? _requiredByApp : _requiredByServer == 1; } + +bool ConsentModule::getConsentLocked(const std::string &name) const { + if (!isRequiredLocked()) { + return true; + } + std::map>::const_iterator group = _groups.find(name); + if (group == _groups.end()) { + return flagOf(_given, name); + } + for (const std::string &feature : group->second) { + if (!flagOf(_given, feature)) { + return false; + } + } + return true; +} +} // namespace cly diff --git a/src/countly.cpp b/src/countly.cpp index 96f22d8..7f50699 100644 --- a/src/countly.cpp +++ b/src/countly.cpp @@ -2,6 +2,7 @@ #include "countly/path_utils.hpp" #include "countly/storage_module_db.hpp" #include "countly/storage_module_memory.hpp" +#include #include #include #include @@ -28,6 +29,11 @@ namespace cly { namespace { const char *const HEALTH_CHECK_AREA_SESSIONS = "sessions"; const char *const HEALTH_CHECK_AREA_EVENTS = "events"; + +/** + * Whether the list of features contains the feature. + */ +bool containsFeature(const std::vector &features, const char *feature) { return std::find(features.begin(), features.end(), feature) != features.end(); } } // namespace @@ -141,6 +147,9 @@ Countly::Countly() { Countly::~Countly() { is_being_disposed = true; + { + std::lock_guard change(consent_change_mutex); + } // Deliberately does not wait for an in-flight remote-config fetch: that would // make destruction block for an HTTP round trip. The fetch runs detached and // holds its own references to everything it touches, so it stays valid on its @@ -613,6 +622,10 @@ void Countly::setUserDetails(const std::map &value) { // 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); + if (is_sdk_initialized && !hasConsent(consent::USERS)) { + log(LogLevel::DEBUG, "[Countly] setUserDetails, the 'users' consent is not given, the call is ignored."); + return; + } SDKLimits lim = configurationModule ? configurationModule->getLimits() : SDKLimits{COUNTLY_MAX_KEY_LENGTH_DEFAULT, COUNTLY_MAX_VALUE_SIZE_DEFAULT, COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT, COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT}; std::map limitedDetails; @@ -641,6 +654,10 @@ void Countly::setCustomUserDetails(const std::map &val // 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); + if (is_sdk_initialized && !hasConsent(consent::USERS)) { + log(LogLevel::DEBUG, "[Countly] setCustomUserDetails, the 'users' consent is not given, the call is ignored."); + return; + } // Determine the post-filter custom property map first, then apply limits. std::map customValue; @@ -690,6 +707,10 @@ void Countly::multiplyCustomUserDetail(const std::string &key, double value) { logger->recordLogCode("w101"); return; } + if (!hasConsent(consent::USERS)) { + log(LogLevel::DEBUG, "[Countly] multiplyCustomUserDetail, the 'users' consent is not given, the call is ignored."); + return; + } if (!std::isfinite(value)) { log(LogLevel::WARNING, "[Countly] multiplyCustomUserDetail, The parameter 'value' must be a finite number. Returning."); logger->recordLogCode("w700"); @@ -719,7 +740,11 @@ void Countly::multiplyCustomUserDetail(const std::string &key, double value) { } void Countly::queueUserDetails() { - queueUserDetails(session_params["user_details"]); + if (hasConsent(consent::USERS)) { + queueUserDetails(session_params["user_details"]); + } else { + log(LogLevel::DEBUG, "[Countly] queueUserDetails, the 'users' consent is not given, the waiting user details are dropped."); + } session_params.erase("user_details"); } @@ -757,6 +782,10 @@ void Countly::setLocation(const std::string &countryCode, const std::string &cit logger->recordLogCode("w101"); return; } + if (!hasConsent(consent::LOCATION)) { + log(LogLevel::DEBUG, "[Countly] setLocation, the 'location' consent is not given, the call is ignored."); + return; + } bool isClearingLocation = countryCode.empty() && city.empty() && gpsCoordinates.empty() && ipAddress.empty(); // unique_lock so the mutex is released on scope exit (incl. throwing json writes); // explicit unlock before the self-locking _sendIndependantLocationRequest() below. @@ -882,10 +911,12 @@ void Countly::_changeDeviceIdWithMerge(const std::string &value) { session_params.erase("old_device_id"); } -/* Change device ID without merge after SDK has been initialized. Events and user details that are still waiting are sent for the old device ID first.*/ +/* Change device ID without merge after SDK has been initialized. Running views, events and user details that are still waiting are sent for the old device ID first, then all consent is removed without telling the server.*/ void Countly::_changeDeviceIdWithoutMerge(const std::string &value) { + std::lock_guard change(consent_change_mutex); log(LogLevel::DEBUG, "[Countly] _changeDeviceIdWithoutMerge, deviceId = [" + value + "]"); + views_module->stopAllViews(); // send all event to server and end current session of old user flushEvents(); if (configuration->manualSessionControl == false) { @@ -897,6 +928,17 @@ void Countly::_changeDeviceIdWithoutMerge(const std::string &value) { if (session_params.contains("user_details")) { queueUserDetails(); } + } + + if (consent_module->isRequired()) { + const std::vector given = consent_module->featuresWithFlag(true); + if (!given.empty()) { + runConsentChange(given, false, ConsentChangeSource::DeviceIdChangedWithoutMerge); + } + } + + { + std::lock_guard lk(*mutex); session_params["device_id"] = value; configuration->deviceId = value; } @@ -1002,6 +1044,8 @@ void Countly::start(const std::string &app_key, const std::string &host, int por requestModule->setConfigurationProvider(configurationModule); views_module->setConfigurationProvider(configurationModule); crash_module->setConfigurationProvider(configurationModule); + views_module->setConsentModule(consent_module); + crash_module->setConsentModule(consent_module); health_check->setStorage(storageModule); is_sdk_initialized = true; // after this point SDK is initialized. @@ -1010,6 +1054,7 @@ void Countly::start(const std::string &app_key, const std::string &host, int por configurationModule->fetchConfigFromStorage(); configurationModule->fetchConfigFromServer(session_params); configurationModule->startServerConfigUpdateTimer(session_params); + activateConsent(); lk.lock(); if (!running) { @@ -1120,13 +1165,20 @@ void Countly::setUpdateInterval(size_t milliseconds) { } void Countly::addEvent(const cly::Event &event) { + std::string eventKey = event.getKey(); if (!is_sdk_initialized) { log(LogLevel::WARNING, "[Countly] addEvent, SDK is not initialized."); - logger->recordLogCode("w101"); + if (hasEventConsent(eventKey)) { + logger->recordLogCode("w101"); + } + return; + } + + if (!hasEventConsent(eventKey)) { + log(LogLevel::DEBUG, "[Countly] addEvent, the consent for the event [" + eventKey + "] is not given, it is not recorded."); return; } - std::string eventKey = event.getKey(); bool isInternalEvent = eventKey.find("[CLY]_") == 0; if (!isInternalEvent) { logger->recordUsage(HEALTH_CHECK_AREA_EVENTS, "record"); @@ -1399,7 +1451,13 @@ bool Countly::beginSession() { return beginSessionInternal(false); } bool Countly::beginSessionInternal(bool automatic) { if (!is_sdk_initialized) { log(LogLevel::WARNING, "[Countly] beginSession, SDK is not initialized."); - logger->recordLogCode("w101"); + if (hasConsent(consent::SESSIONS)) { + logger->recordLogCode("w101"); + } + return false; + } + if (!hasConsent(consent::SESSIONS)) { + log(LogLevel::DEBUG, "[Countly] beginSession, the 'sessions' consent is not given, no session begins."); return false; } if (!automatic) { @@ -1429,28 +1487,36 @@ bool Countly::beginSessionInternal(bool automatic) { std::map data = {{"timestamp", std::to_string(timestamp.count())}, {"app_key", session_params["app_key"].get()}, {"device_id", session_params["device_id"].get()}, {"begin_session", "1"}}; - if (session_params.contains("city") && session_params["city"].is_string() && !session_params["city"].get().empty()) { - data["city"] = session_params["city"].get(); - } + if (isLocationOn()) { + if (session_params.contains("city") && session_params["city"].is_string() && !session_params["city"].get().empty()) { + data["city"] = session_params["city"].get(); + } - if (session_params.contains("location") && session_params["location"].is_string() && !session_params["location"].get().empty()) { - data["location"] = session_params["location"].get(); - } + if (session_params.contains("location") && session_params["location"].is_string() && !session_params["location"].get().empty()) { + data["location"] = session_params["location"].get(); + } - if (session_params.contains("country_code") && session_params["country_code"].is_string() && !session_params["country_code"].get().empty()) { - data["country_code"] = session_params["country_code"].get(); - } + if (session_params.contains("country_code") && session_params["country_code"].is_string() && !session_params["country_code"].get().empty()) { + data["country_code"] = session_params["country_code"].get(); + } - if (session_params.contains("ip_address") && session_params["ip_address"].is_string() && !session_params["ip_address"].get().empty()) { - data["ip_address"] = session_params["ip_address"].get(); + if (session_params.contains("ip_address") && session_params["ip_address"].is_string() && !session_params["ip_address"].get().empty()) { + data["ip_address"] = session_params["ip_address"].get(); + } + } else { + data["location"] = ""; } if (session_params.contains("user_details")) { - data["user_details"] = session_params["user_details"].dump(); + if (hasConsent(consent::USERS)) { + data["user_details"] = session_params["user_details"].dump(); + } else { + log(LogLevel::DEBUG, "[Countly] beginSession, the 'users' consent is not given, the waiting user details are dropped."); + } session_params.erase("user_details"); } - if (configuration->metrics.size() > 0) { + if (configuration->metrics.size() > 0 && hasConsent(consent::METRICS)) { data["metrics"] = configuration->metrics.dump(); } @@ -1479,7 +1545,17 @@ bool Countly::updateSession() { return updateSessionInternal(false); } bool Countly::updateSessionInternal(bool automatic) { if (!is_sdk_initialized) { log(LogLevel::WARNING, "[Countly] updateSession, SDK is not initialized."); - logger->recordLogCode("w101"); + if (hasConsent(consent::SESSIONS)) { + logger->recordLogCode("w101"); + } + return false; + } + if (!hasConsent(consent::SESSIONS)) { + if (automatic) { + packEvents(); + return true; + } + log(LogLevel::DEBUG, "[Countly] updateSession, the 'sessions' consent is not given, the session is not updated."); return false; } if (!automatic) { @@ -1642,7 +1718,13 @@ bool Countly::endSession() { return endSessionInternal(false); } bool Countly::endSessionInternal(bool automatic) { if (!is_sdk_initialized && !is_being_disposed) { log(LogLevel::WARNING, "[Countly] endSession, SDK is not initialized."); - logger->recordLogCode("w101"); + if (hasConsent(consent::SESSIONS)) { + logger->recordLogCode("w101"); + } + return false; + } + if (!hasConsent(consent::SESSIONS)) { + log(LogLevel::DEBUG, "[Countly] endSession, the 'sessions' consent is not given, there is no session to end."); return false; } if (!automatic) { @@ -2143,7 +2225,7 @@ void Countly::sendHealthCheck() { std::map data = {{"hc", snapshot.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace)}}; nlohmann::json::const_iterator appVersion = configuration->metrics.find("_app_version"); - if (appVersion != configuration->metrics.end() && appVersion->is_string() && !appVersion->get().empty()) { + if (hasConsent(consent::METRICS) && appVersion != configuration->metrics.end() && appVersion->is_string() && !appVersion->get().empty()) { data["metrics"] = nlohmann::json({{"_app_version", appVersion->get()}}).dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); } @@ -2179,6 +2261,10 @@ void Countly::updateRemoteConfig() { logger->recordLogCode("w101"); return; } + if (!hasConsent(consent::REMOTE_CONFIG)) { + log(LogLevel::DEBUG, "[Countly] updateRemoteConfig, the 'remote-config' consent is not given, nothing is downloaded."); + return; + } std::unique_lock lk(*mutex); if (!session_params["app_key"].is_string() || !session_params["device_id"].is_string()) { @@ -2208,6 +2294,10 @@ void Countly::updateRemoteConfigFor(std::string *keys, size_t key_count) { logger->recordLogCode("w101"); return; } + if (!hasConsent(consent::REMOTE_CONFIG)) { + log(LogLevel::DEBUG, "[Countly] updateRemoteConfigFor, the 'remote-config' consent is not given, nothing is downloaded."); + return; + } 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()}}; @@ -2231,6 +2321,10 @@ void Countly::updateRemoteConfigExcept(std::string *keys, size_t key_count) { logger->recordLogCode("w101"); return; } + if (!hasConsent(consent::REMOTE_CONFIG)) { + log(LogLevel::DEBUG, "[Countly] updateRemoteConfigExcept, the 'remote-config' consent is not given, nothing is downloaded."); + return; + } 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()}}; @@ -2247,4 +2341,298 @@ void Countly::updateRemoteConfigExcept(std::string *keys, size_t key_count) { // Merges into the stored values, since this fetches only some of them. startRemoteConfigFetch(data, true, "updateRemoteConfigExcept"); } + +void Countly::setRequiresConsent(bool required) { + if (is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] setRequiresConsent, This method can't be called after SDK initialization. Returning."); + return; + } + + consent_module->setRequiredByApp(required); +} + +void Countly::setConsentEnabled(const std::vector &featureNames) { + if (is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] setConsentEnabled, This method can't be called after SDK initialization. Returning."); + return; + } + + std::vector features; + for (const std::string &name : featureNames) { + if (!ConsentModule::isFeature(name)) { + log(LogLevel::WARNING, "[Countly] setConsentEnabled, [" + name + "] is not a feature this SDK offers, it is ignored."); + logger->recordLogCode("w402"); + continue; + } + features.push_back(name); + } + consent_module->setOnlyGiven(features); +} + +void Countly::giveConsent(const std::vector &featureNames) { changeConsent(featureNames, true, "giveConsent"); } + +void Countly::removeConsent(const std::vector &featureNames) { changeConsent(featureNames, false, "removeConsent"); } + +void Countly::giveConsentAll() { changeConsent(consent::allFeatures(), true, "giveConsentAll"); } + +void Countly::removeConsentAll() { changeConsent(consent::allFeatures(), false, "removeConsentAll"); } + +void Countly::setConsent(const std::vector &featureNames, bool isConsentGiven) { changeConsent(featureNames, isConsentGiven, "setConsent"); } + +bool Countly::getConsent(const std::string &featureName) { + if (!is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] getConsent, SDK is not initialized."); + logger->recordLogCode("w101"); + return false; + } + if (!consent_module->isRequired()) { + return true; + } + if (!consent_module->isKnownName(featureName)) { + log(LogLevel::WARNING, "[Countly] getConsent, [" + featureName + "] is neither a feature this SDK offers nor a feature group."); + logger->recordLogCode("w402"); + return false; + } + return consent_module->getConsent(featureName); +} + +void Countly::createFeatureGroup(const std::string &groupName, const std::vector &featureNames) { + if (!is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] createFeatureGroup, SDK is not initialized."); + logger->recordLogCode("w101"); + return; + } + if (groupName.empty()) { + log(LogLevel::WARNING, "[Countly] createFeatureGroup, The parameter 'groupName' can't be empty. Returning."); + logger->recordLogCode("w700"); + return; + } + if (ConsentModule::isFeature(groupName)) { + log(LogLevel::WARNING, "[Countly] createFeatureGroup, a group can't take the name of the feature [" + groupName + "]. Returning."); + logger->recordLogCode("w700"); + return; + } + if (featureNames.empty()) { + log(LogLevel::WARNING, "[Countly] createFeatureGroup, no feature was given for the group [" + groupName + "]. Returning."); + logger->recordLogCode("w402"); + return; + } + + std::vector features; + for (const std::string &name : featureNames) { + if (!ConsentModule::isFeature(name)) { + log(LogLevel::WARNING, "[Countly] createFeatureGroup, [" + name + "] is not a feature this SDK offers, it is ignored."); + logger->recordLogCode("w402"); + continue; + } + features.push_back(name); + } + if (features.empty()) { + log(LogLevel::DEBUG, "[Countly] createFeatureGroup, the group [" + groupName + "] has no feature this SDK offers, it is not created."); + return; + } + consent_module->createGroup(groupName, features); +} + +void Countly::setConsentFeatureGroup(const std::string &groupName, bool isConsentGiven) { changeConsent({groupName}, isConsentGiven, "setConsentFeatureGroup"); } + +void Countly::checkAllConsent() { + if (!is_sdk_initialized) { + log(LogLevel::WARNING, "[Countly] checkAllConsent, SDK is not initialized."); + logger->recordLogCode("w101"); + return; + } + + std::ostringstream state; + state << "[Countly] checkAllConsent, consent is " << (consent_module->isRequired() ? "required" : "not required"); + for (const std::string &feature : consent::allFeatures()) { + state << ", " << feature << ": " << (consent_module->getConsent(feature) ? "given" : "not given"); + } + log(LogLevel::INFO, state.str()); +} + +void Countly::ConsentRequirementChanged() { + std::lock_guard change(consent_change_mutex); + if (is_being_disposed || configurationModule == nullptr) { + return; + } + + const int requiredByServer = configurationModule->getConsentRequired(); + if (!consent_active || !consent_module->requirementChangesWith(requiredByServer)) { + consent_module->setRequiredByServer(requiredByServer); + return; + } + + const std::vector withoutConsent = consent_module->featuresWithFlag(false); + if (consent_module->isRequired()) { + log(LogLevel::INFO, "[Countly] ConsentRequirementChanged, the server lifted the consent requirement."); + consent_module->setRequiredByServer(requiredByServer); + consentChanged(withoutConsent, true, ConsentChangeSource::ServerRequirement); + return; + } + + log(LogLevel::INFO, "[Countly] ConsentRequirementChanged, the server requires consent."); + consentWillBeRemoved(withoutConsent); + consent_module->setRequiredByServer(requiredByServer); + queueConsentState(); + consentChanged(withoutConsent, false, ConsentChangeSource::ServerRequirement); +} + +bool Countly::hasConsent(const char *feature) const { return consent_module->getConsent(feature); } + +bool Countly::hasEventConsent(const std::string &key) const { + if (key == "[CLY]_view") { + return hasConsent(consent::VIEWS); + } + if (key == "[CLY]_nps" || key == "[CLY]_survey") { + return hasConsent(consent::FEEDBACK); + } + if (key == "[CLY]_star_rating") { + return hasConsent(consent::STAR_RATING) || hasConsent(consent::FEEDBACK); + } + if (key == "[CLY]_orientation") { + return hasConsent(consent::USERS); + } + if (key == "[CLY]_push_action") { + return hasConsent("push"); + } + if (key == "[CLY]_action") { + return hasConsent("clicks") || hasConsent("scrolls"); + } + if (key == "[CLY]_content_shown" || key == "[CLY]_content_interacted") { + return hasConsent("content"); + } + return hasConsent(consent::EVENTS); +} + +bool Countly::isLocationOn() { return hasConsent(consent::LOCATION) && configurationModule->isLocationTrackingEnabled(); } + +void Countly::changeConsent(const std::vector &names, bool given, const char *caller) { + if (LoggerModule::isInsideCallback()) { + return; + } + if (!is_sdk_initialized) { + log(LogLevel::WARNING, std::string("[Countly] ") + caller + ", SDK is not initialized."); + logger->recordLogCode("w101"); + return; + } + + std::lock_guard change(consent_change_mutex); + if (is_being_disposed) { + return; + } + if (!consent_module->isRequired()) { + log(LogLevel::INFO, std::string("[Countly] ") + caller + ", consent is not required, the call is ignored."); + return; + } + if (names.empty()) { + log(LogLevel::WARNING, std::string("[Countly] ") + caller + ", no feature was given, the call is ignored."); + logger->recordLogCode("w402"); + return; + } + + std::vector unknown; + const std::vector features = consent_module->expand(names, unknown); + for (const std::string &name : unknown) { + log(LogLevel::WARNING, std::string("[Countly] ") + caller + ", [" + name + "] is neither a feature this SDK offers nor a feature group, it is ignored."); + logger->recordLogCode("w402"); + } + + const std::vector changing = consent_module->featuresToChange(features, given); + if (changing.empty()) { + log(LogLevel::DEBUG, std::string("[Countly] ") + caller + ", the consent of the given features does not change."); + return; + } + runConsentChange(changing, given, ConsentChangeSource::App); +} + +void Countly::runConsentChange(const std::vector &features, bool given, ConsentChangeSource source) { + log(LogLevel::DEBUG, std::string("[Countly] runConsentChange, ") + (given ? "giving" : "removing") + " consent for " + std::to_string(features.size()) + " feature(s)"); + if (!given) { + consentWillBeRemoved(features); + } + consent_module->setGiven(features, given); + if (source != ConsentChangeSource::DeviceIdChangedWithoutMerge && consent_module->isRequired()) { + queueConsentState(); + } + consentChanged(features, given, source); +} + +void Countly::consentWillBeRemoved(const std::vector &features) { + if (containsFeature(features, consent::VIEWS)) { + views_module->stopAllViews(); + } + if (containsFeature(features, consent::SESSIONS)) { + endSessionInternal(true); + } +} + +void Countly::consentChanged(const std::vector &features, bool given, ConsentChangeSource source) { + if (!given) { + if (containsFeature(features, consent::CRASHES)) { + crash_module->clearBreadcrumbs(); + } + if (containsFeature(features, consent::USERS)) { + std::lock_guard lk(*mutex); + session_params.erase("user_details"); + } + if (containsFeature(features, consent::LOCATION)) { + clearStoredLocation(); + if (source != ConsentChangeSource::DeviceIdChangedWithoutMerge) { + _sendIndependantLocationRequest(); + } + } + return; + } + + bool sessionBegan = false; + if (containsFeature(features, consent::SESSIONS) && configuration->manualSessionControl == false) { + bool sessionRunning = false; + { + std::lock_guard lk(*mutex); + sessionRunning = began_session; + } + sessionBegan = !sessionRunning && beginSessionInternal(true); + } + if (containsFeature(features, consent::REMOTE_CONFIG)) { + bool automaticDownload = false; + { + std::lock_guard lk(*mutex); + automaticDownload = remote_config_enabled; + } + if (automaticDownload && !sessionBegan) { + updateRemoteConfig(); + } + } +} + +void Countly::queueConsentState() { + const std::string state = consent_module->state().dump(); + log(LogLevel::DEBUG, "[Countly] queueConsentState, consent = [" + state + "]"); + std::lock_guard lk(*mutex); + requestModule->addRequestToQueue({{"consent", state}}); +} + +void Countly::clearStoredLocation() { + std::lock_guard lk(*mutex); + session_params.erase("city"); + session_params.erase("location"); + session_params.erase("country_code"); + session_params.erase("ip_address"); +} + +void Countly::activateConsent() { + std::lock_guard change(consent_change_mutex); + consent_module->setRequiredByServer(configurationModule->getConsentRequired()); + consent_active = true; + if (!consent_module->isRequired()) { + return; + } + + queueConsentState(); + if (!hasConsent(consent::LOCATION) && (!hasConsent(consent::SESSIONS) || !configurationModule->isSessionTrackingEnabled())) { + clearStoredLocation(); + _sendIndependantLocationRequest(); + } +} } // namespace cly diff --git a/src/crash_module.cpp b/src/crash_module.cpp index 3b5d66a..bececd2 100644 --- a/src/crash_module.cpp +++ b/src/crash_module.cpp @@ -1,4 +1,5 @@ #include "countly/crash_module.hpp" +#include "countly/consent_module.hpp" #include "countly/internal_limits.hpp" #include "countly/request_module.hpp" @@ -18,10 +19,22 @@ class CrashModule::CrashModuleImpl { std::shared_ptr _requestModule; std::shared_ptr _mutex; std::weak_ptr _configProvider; + std::shared_ptr _consent; CrashModuleImpl(std::shared_ptr config, std::shared_ptr logger, std::shared_ptr requestModule, std::shared_ptr mutex) : _configuration(config), _logger(logger), _requestModule(requestModule), _mutex(mutex) {} // destructor to reset logger ~CrashModuleImpl() { _logger.reset(); } + + /** + * Whether the "crashes" consent allows the call, logged at debug level when it does not. + */ + bool hasConsent(const char *caller) { + if (!_consent || _consent->getConsent(consent::CRASHES)) { + return true; + } + _logger->log(LogLevel::DEBUG, std::string("[Countly] [CrashModule] ") + caller + ", the 'crashes' consent is not given, the call is ignored."); + return false; + } }; // destructor to reset implementation @@ -35,6 +48,9 @@ CrashModule::CrashModule(std::shared_ptr config, std::shar // function to add breadcrumb void CrashModule::addBreadcrumb(const std::string &value) { + if (!impl->hasConsent("addBreadcrumb")) { + return; + } SDKLimits lim{COUNTLY_MAX_KEY_LENGTH_DEFAULT, COUNTLY_MAX_VALUE_SIZE_DEFAULT, COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT, COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT}; if (std::shared_ptr config = impl->_configProvider.lock()) { lim = config->getLimits(); @@ -57,6 +73,9 @@ void CrashModule::addBreadcrumb(const std::string &value) { void CrashModule::recordException(const std::string &title, const std::string &stackTrace, const bool fatal, const std::map &crashMetrics, const std::map &segmentation) { impl->_logger->log(LogLevel::INFO, cly::utils::format_string("[Countly] [CrashModule] recordException, title = [%s], stackTrace = [%s]", title.c_str(), stackTrace.c_str())); + if (!impl->hasConsent("recordException")) { + return; + } impl->_logger->recordUsage("crashes", "record"); SDKLimits lim{COUNTLY_MAX_KEY_LENGTH_DEFAULT, COUNTLY_MAX_VALUE_SIZE_DEFAULT, COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT, COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT}; @@ -124,4 +143,11 @@ void CrashModule::recordException(const std::string &title, const std::string &s void CrashModule::setConfigurationProvider(std::weak_ptr provider) { impl->_configProvider = std::move(provider); } +void CrashModule::setConsentModule(std::shared_ptr consent) { impl->_consent = std::move(consent); } + +void CrashModule::clearBreadcrumbs() { + std::lock_guard lk(*impl->_mutex); + impl->_breadCrumbs.clear(); +} + } // namespace cly \ No newline at end of file diff --git a/src/views_module.cpp b/src/views_module.cpp index b97acc7..30bec9f 100644 --- a/src/views_module.cpp +++ b/src/views_module.cpp @@ -1,9 +1,11 @@ #include "countly/views_module.hpp" +#include "countly/consent_module.hpp" #include "countly/internal_limits.hpp" #include #include +#include #define CLY_VIEW_KEY "[CLY]_view" @@ -82,10 +84,45 @@ class ViewsModule::ViewModuleImpl { public: std::shared_ptr _logger; std::weak_ptr _configProvider; + std::shared_ptr _consent; ViewModuleImpl(cly::CountlyDelegates *cly, std::shared_ptr logger) : _cly(cly), _logger(logger) {} ~ViewModuleImpl() { _logger.reset(); } + /** + * Whether the "views" consent allows the call, logged at debug level when it does not. + */ + bool hasConsent(const char *caller) { + if (!_consent || _consent->getConsent(consent::VIEWS)) { + return true; + } + _logger->log(LogLevel::DEBUG, std::string("[Countly] [ViewsModule] ") + caller + ", the 'views' consent is not given, the call is ignored."); + return false; + } + + /** + * Takes every running view out of the map and records its end event, or drops them while view tracking is off. + */ + void _stopAllViews() { + std::vector> running; + { + std::lock_guard lk(_views_mutex); + for (const auto &entry : _viewsStartTime) { + running.push_back(entry.second); + } + _viewsStartTime.clear(); + } + + std::shared_ptr config = _configProvider.lock(); + if (!config || config->isViewTrackingEnabled() == false) { + _logger->log(LogLevel::DEBUG, "[Countly] [ViewsModule] _stopAllViews, View tracking is disabled, the running views are dropped."); + return; + } + for (const std::shared_ptr &view : running) { + _recordView(view, {}, false); + } + } + std::string _openView(const std::string &name, const std::map &segmentation) { SDKLimits lim{COUNTLY_MAX_KEY_LENGTH_DEFAULT, COUNTLY_MAX_VALUE_SIZE_DEFAULT, COUNTLY_MAX_SEGMENTATION_VALUES_DEFAULT, COUNTLY_MAX_BREADCRUMB_COUNT_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINES_PER_THREAD_DEFAULT, COUNTLY_MAX_STACK_TRACE_LINE_LENGTH_DEFAULT}; if (std::shared_ptr config = _configProvider.lock()) { @@ -201,6 +238,9 @@ std::string ViewsModule::openView(const std::string &name, const std::map_logger->log(cly::LogLevel::INFO, cly::utils::format_string("[Countly] [ViewsModule] openView, name = [%s], segmentation = [%s]", name.c_str(), utils::mapToString(segmentation).c_str())); + if (!impl->hasConsent("openView")) { + return {}; + } impl->_logger->recordUsage("views", "start"); if (name.empty()) { impl->_logger->log(cly::LogLevel::WARNING, "[Countly] [ViewsModule] openView, view name can not be null or empty!"); @@ -214,6 +254,9 @@ std::string ViewsModule::openView(const std::string &name, const std::map_logger->log(cly::LogLevel::INFO, cly::utils::format_string("[Countly] [ViewsModule] closeViewWithName, name = [%s]", name.c_str())); + if (!impl->hasConsent("closeViewWithName")) { + return; + } impl->_logger->recordUsage("views", "stop"); if (name.empty()) { impl->_logger->log(cly::LogLevel::WARNING, "[Countly] [ViewsModule] closeViewWithName, view name can not be null or empty!"); @@ -226,6 +269,9 @@ void ViewsModule::closeViewWithName(const std::string &name) { void ViewsModule::closeViewWithID(const std::string &viewId) { impl->_logger->log(cly::LogLevel::INFO, cly::utils::format_string("[Countly] [ViewsModule] closeViewWithID, viewId = [%s]", viewId.c_str())); + if (!impl->hasConsent("closeViewWithID")) { + return; + } impl->_logger->recordUsage("views", "stop"); if (viewId.empty()) { impl->_logger->log(cly::LogLevel::WARNING, "[Countly] [ViewsModule] closeViewWithID, viewId can not be null or empty!"); @@ -238,4 +284,8 @@ void ViewsModule::closeViewWithID(const std::string &viewId) { void ViewsModule::setConfigurationProvider(std::weak_ptr provider) { impl->_configProvider = std::move(provider); } +void ViewsModule::setConsentModule(std::shared_ptr consent) { impl->_consent = std::move(consent); } + +void ViewsModule::stopAllViews() { impl->_stopAllViews(); } + } // namespace cly \ No newline at end of file diff --git a/tests/consent.cpp b/tests/consent.cpp new file mode 100644 index 0000000..1cbdb32 --- /dev/null +++ b/tests/consent.cpp @@ -0,0 +1,1127 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "doctest.h" + +#include "nlohmann/json.hpp" +#include "test_utils.hpp" + +using namespace cly; +using namespace test_utils; +using json = nlohmann::json; + +namespace { +/** + * The features the C++ SDK offers for consent, as the server receives their names. + */ +const std::vector ALL_FEATURES = {"sessions", "events", "views", "location", "crashes", "users", "remote-config", "metrics", "feedback", "star-rating"}; + +/** + * The consent state the SDK sends when exactly the given features have consent. + */ +json stateWith(const std::vector &given) { + json state = json::object(); + for (const std::string &feature : ALL_FEATURES) { + state[feature] = false; + } + for (const std::string &feature : given) { + state[feature] = true; + } + return state; +} + +/** + * Every feature except the given one. + */ +std::vector allExcept(const std::string &removed) { + std::vector features; + for (const std::string &feature : ALL_FEATURES) { + if (feature != removed) { + features.push_back(feature); + } + } + return features; +} + +/** + * The SDK behavior settings server of these tests. It answers a settings fetch with 'answer', fails + * it like the shared fake client while 'answer' is null, and holds the answer back while 'hold' is set. + */ +struct SettingsServer { + std::mutex mutex; + std::condition_variable changed; + json answer; + bool hold = false; + + void reset(const json &newAnswer = nullptr, bool holdAnswer = false) { + std::lock_guard lock(mutex); + answer = newAnswer; + hold = holdAnswer; + changed.notify_all(); + } + + void release() { + std::lock_guard lock(mutex); + hold = false; + changed.notify_all(); + } +}; + +SettingsServer settings_server; + +/** + * Releases a held settings answer when a test ends, so destroying the instance does not wait for it. + */ +struct ReleaseSettingsOnExit { + ~ReleaseSettingsOnExit() { settings_server.release(); } +}; + +/** + * The fake HTTP client of these tests: captures every call like fakeSendHTTP, answers the settings + * fetch through the settings server, and answers a full remote config download with every value. + */ +HTTPResponse consentSendHTTP(bool use_post, const std::string &url, const std::string &data) { + HTTPResponse response = fakeSendHTTP(use_post, url, data); + if (url == "/o/sdk" && data.find("method=fetch_remote_config") != std::string::npos && data.find("keys=") == std::string::npos) { + return HTTPResponse{true, json({{"color", "#FF9900"}, {"playerQueueTimeout", 32}}), 200, ""}; + } + if (url != "/o/sdk" || data.find("method=sc") == std::string::npos) { + return response; + } + std::unique_lock lock(settings_server.mutex); + settings_server.changed.wait_for(lock, std::chrono::seconds(10), [] { return !settings_server.hold; }); + if (settings_server.answer.is_null()) { + return response; + } + return HTTPResponse{true, settings_server.answer, 200, ""}; +} + +/** + * Waits until the condition holds, at most five seconds. + */ +template bool waitUntil(Condition condition) { + const std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!condition()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return true; +} + +/** + * Sets the instance up for a consent test: the fake client, the test device ID and database, no + * periodic settings fetch, and an update loop that stays out of the test's way and stops at once. + */ +void prepare(Countly &countly) { + countly.setHTTPClient(consentSendHTTP); + countly.setDeviceID(COUNTLY_TEST_DEVICE_ID); + countly.SetPath(TEST_DATABASE_NAME); + countly.disableSDKBehaviorSettingsUpdates(); + countly.enableImmediateRequestOnStop(); + countly.setUpdateInterval(60000); +} + +void start(Countly &countly) { countly.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); } + +/** + * Starts a fresh default instance prepared for a consent test, with the settings answer given. + */ +Countly &freshInstance(const json &settingsAnswer = nullptr, bool holdSettings = false) { + clearSDK(); + settings_server.reset(settingsAnswer, holdSettings); + Countly &countly = Countly::getInstance(); + prepare(countly); + return countly; +} + +/** + * Sends the queued requests and returns those that went to /i, oldest first. Forgets every call + * captured so far. + */ +std::vector takeRequests(Countly &countly) { + countly.processRQDebug(); + waitUntil([&countly] { return countly.checkRQSize() == 0; }); + std::vector requests; + for (size_t index = 0; index < http_call_queue.size(); index++) { + HTTPCall call = http_call_queue.at(index); + if (call.url == "/i") { + requests.push_back(call); + } + } + http_call_queue.clear(); + return requests; +} + +bool has(const HTTPCall &call, const std::string &key) { return call.data.find(key) != call.data.end(); } + +std::string valueOf(const HTTPCall &call, const std::string &key) { + std::map::const_iterator found = call.data.find(key); + return found == call.data.end() ? "" : found->second; +} + +json consentOf(const HTTPCall &call) { + REQUIRE(has(call, "consent")); + return json::parse(call.data.at("consent")); +} + +/** + * The events the given requests carry, in order. + */ +std::vector eventsIn(const std::vector &requests) { + std::vector events; + for (const HTTPCall &call : requests) { + if (has(call, "events")) { + for (const json &event : json::parse(call.data.at("events"))) { + events.push_back(event); + } + } + } + return events; +} + +/** + * The keys of the events waiting in the event queue, in order. + */ +std::vector queuedEventKeys(Countly &countly) { + std::vector keys; + for (const std::string &event : countly.debugReturnStateOfEQ()) { + keys.push_back(json::parse(event)["key"].get()); + } + return keys; +} + +/** + * How many full or partial remote config downloads the fake client received since the last takeRequests. + */ +size_t remoteConfigFetches() { + size_t count = 0; + for (size_t index = 0; index < http_call_queue.size(); index++) { + HTTPCall call = http_call_queue.at(index); + if (call.url == "/o/sdk" && valueOf(call, "method") == "fetch_remote_config") { + count++; + } + } + return count; +} + +/** + * The count of a log code in the instance's health check counters, 0 when it was not recorded. + */ +long long logCode(Countly &countly, const std::string &code) { + json hc = countly.debugHealthCheck()->snapshot(); + return hc["lc"].contains(code) ? hc["lc"][code].get() : 0; +} + +/** + * Waits until the fake HTTP client received a health check, at most five seconds. + */ +bool waitForHealthCheck() { + return waitUntil([] { return !health_check_call_queue.empty(); }); +} + +const std::map CRASH_METRICS = {{"_os", "Windows"}, {"_app_version", "2.4.1"}}; +} // namespace + +TEST_CASE("consent: required with nothing given, only the consent state and a location erase are sent") { + Countly &countly = freshInstance(); + countly.setRequiresConsent(true); + countly.setMetrics("Windows", "10", "PC", "1920x1080", "", "2.4.1"); + countly.enableRemoteConfig(); + start(countly); + + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 2); + CHECK(consentOf(requests[0]) == stateWith({})); + CHECK(valueOf(requests[1], "location") == ""); + CHECK(!has(requests[1], "begin_session")); + + REQUIRE(waitForHealthCheck()); + CHECK(!has(health_check_call_queue.front(), "metrics")); + + countly.addEvent(Event("click", 1)); + countly.RecordEvent("[CLY]_view", 1); + CHECK(countly.views().openView("main").empty()); + countly.crash().addBreadcrumb("crumb"); + countly.crash().recordException("title", "trace", false, CRASH_METRICS); + countly.setUserDetails({{"name", "Alice"}}); + countly.setCustomUserDetails({{"level", "3"}}); + countly.multiplyCustomUserDetail("score", 2); + countly.setLocation("TR", "Istanbul", "", ""); + countly.updateRemoteConfig(); + CHECK_FALSE(countly.beginSession()); + CHECK_FALSE(countly.updateSession()); + CHECK_FALSE(countly.endSession()); + + CHECK(countly.checkEQSize() == 0); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + CHECK(remoteConfigFetches() == 0); + CHECK(takeRequests(countly).empty()); + CHECK(countly.debugHealthCheck()->snapshot()["fu"].empty()); + clearSDK(); +} + +TEST_CASE("consent: giving consent sends the full state once and starts the features") { + Countly &countly = freshInstance(); + countly.setRequiresConsent(true); + start(countly); + takeRequests(countly); + + countly.giveConsent({cly::consent::SESSIONS, cly::consent::EVENTS}); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 2); + CHECK(consentOf(requests[0]) == stateWith({"sessions", "events"})); + CHECK(valueOf(requests[1], "begin_session") == "1"); + + countly.addEvent(Event("click", 1)); + countly.flushEvents(std::chrono::seconds(1)); + std::vector events = eventsIn(takeRequests(countly)); + REQUIRE(events.size() == 1); + CHECK(events[0]["key"] == "click"); + + countly.giveConsent({cly::consent::SESSIONS, cly::consent::EVENTS}); + CHECK(takeRequests(countly).empty()); + + countly.giveConsent({cly::consent::EVENTS, cly::consent::VIEWS}); + requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith({"sessions", "events", "views"})); + clearSDK(); +} + +TEST_CASE("consent: removing consent stops the feature and runs its clean-up") { + SUBCASE("views: every running view ends, later view calls record nothing") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled(cly::consent::allFeatures()); + start(countly); + takeRequests(countly); + + const std::string home = countly.views().openView("Home"); + countly.views().openView("Settings"); + REQUIRE(!home.empty()); + countly.removeConsent({cly::consent::VIEWS}); + + std::vector queued = countly.debugReturnStateOfEQ(); + REQUIRE(queued.size() == 4); + std::set ended; + for (size_t index = 2; index < queued.size(); index++) { + json event = json::parse(queued[index]); + CHECK(event["key"] == "[CLY]_view"); + CHECK(!event["segmentation"].contains("visit")); + ended.insert(event["segmentation"]["name"].get()); + } + CHECK(ended == std::set({"Home", "Settings"})); + + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith(allExcept("views"))); + + CHECK(countly.views().openView("Profile").empty()); + countly.views().closeViewWithID(home); + CHECK(countly.checkEQSize() == 4); + clearSDK(); + } + + SUBCASE("sessions: the running session ends, queued events are still sent") { + Countly &countly = freshInstance(); + countly.setRequiresConsent(true); + countly.setConsentEnabled(cly::consent::allFeatures()); + start(countly); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 2); + CHECK(valueOf(requests[1], "begin_session") == "1"); + + countly.addEvent(Event("before", 1)); + countly.removeConsent({cly::consent::SESSIONS}); + requests = takeRequests(countly); + REQUIRE(requests.size() == 2); + CHECK(valueOf(requests[0], "end_session") == "1"); + CHECK(consentOf(requests[1]) == stateWith(allExcept("sessions"))); + + CHECK_FALSE(countly.updateSession()); + countly.flushEvents(std::chrono::seconds(1)); + requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(!has(requests[0], "session_duration")); + std::vector events = eventsIn(requests); + REQUIRE(events.size() == 1); + CHECK(events[0]["key"] == "before"); + clearSDK(); + } + + SUBCASE("location: the stored location is cleared and erased, later session starts carry the empty location") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled(cly::consent::allFeatures()); + start(countly); + takeRequests(countly); + + countly.setLocation("TR", "Istanbul", "41.0082,28.9784", ""); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "country_code") == "TR"); + CHECK(valueOf(requests[0], "city") == "Istanbul"); + + countly.removeConsent({cly::consent::LOCATION}); + requests = takeRequests(countly); + REQUIRE(requests.size() == 2); + CHECK(consentOf(requests[0]) == stateWith(allExcept("location"))); + CHECK(valueOf(requests[1], "location") == ""); + CHECK(!has(requests[1], "country_code")); + CHECK(!has(requests[1], "city")); + + REQUIRE(countly.beginSession()); + requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "begin_session") == "1"); + CHECK(valueOf(requests[0], "location") == ""); + CHECK(!has(requests[0], "country_code")); + CHECK(!has(requests[0], "city")); + clearSDK(); + } + + SUBCASE("crashes: the breadcrumbs are cleared") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled(cly::consent::allFeatures()); + start(countly); + takeRequests(countly); + + countly.crash().addBreadcrumb("kept"); + countly.crash().recordException("first", "trace", false, CRASH_METRICS); + countly.crash().addBreadcrumb("dropped"); + countly.removeConsent({cly::consent::CRASHES}); + countly.giveConsent({cly::consent::CRASHES}); + countly.crash().recordException("second", "trace", false, CRASH_METRICS); + + std::vector crashes; + for (const HTTPCall &call : takeRequests(countly)) { + if (has(call, "crash")) { + crashes.push_back(json::parse(call.data.at("crash"))); + } + } + REQUIRE(crashes.size() == 2); + CHECK(crashes[0]["_logs"] == "kept\n"); + CHECK(crashes[1]["_logs"] == ""); + clearSDK(); + } + + SUBCASE("users: details waiting for the first session are dropped") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::SESSIONS, cly::consent::USERS}); + countly.setUserDetails({{"name", "Alice"}}); + start(countly); + takeRequests(countly); + + countly.removeConsent({cly::consent::USERS}); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith({"sessions"})); + + REQUIRE(countly.beginSession()); + requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "begin_session") == "1"); + CHECK(!has(requests[0], "user_details")); + clearSDK(); + } + + SUBCASE("users, with the consent kept: details waiting for the first session go with it") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::SESSIONS, cly::consent::USERS}); + countly.setUserDetails({{"name", "Alice"}}); + start(countly); + takeRequests(countly); + + REQUIRE(countly.beginSession()); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + REQUIRE(has(requests[0], "user_details")); + CHECK(json::parse(requests[0].data.at("user_details")) == json({{"name", "Alice"}})); + clearSDK(); + } +} + +TEST_CASE("consent: a device ID change without merge removes all consent without telling the server") { + Countly &countly = freshInstance(); + countly.setRequiresConsent(true); + countly.setConsentEnabled(cly::consent::allFeatures()); + start(countly); + takeRequests(countly); + + countly.views().openView("Settings"); + countly.addEvent(Event("click", 1)); + countly.setDeviceID("new-user", false); + + std::vector requests = takeRequests(countly); + REQUIRE(!requests.empty()); + int endSessions = 0; + for (const HTTPCall &call : requests) { + CHECK(valueOf(call, "device_id") == COUNTLY_TEST_DEVICE_ID); + CHECK(!has(call, "consent")); + CHECK(!has(call, "location")); + if (valueOf(call, "end_session") == "1") { + endSessions++; + } + } + CHECK(endSessions == 1); + std::vector events = eventsIn(requests); + REQUIRE(events.size() == 3); + CHECK(events[0]["key"] == "[CLY]_view"); + CHECK(events[0]["segmentation"]["visit"] == "1"); + CHECK(events[1]["key"] == "click"); + CHECK(events[2]["key"] == "[CLY]_view"); + CHECK(events[2]["segmentation"]["name"] == "Settings"); + CHECK(!events[2]["segmentation"].contains("visit")); + + countly.addEvent(Event("ignored", 1)); + CHECK(countly.checkEQSize() == 0); + CHECK(takeRequests(countly).empty()); + + countly.giveConsent({cly::consent::SESSIONS}); + requests = takeRequests(countly); + REQUIRE(requests.size() == 2); + CHECK(valueOf(requests[0], "device_id") == "new-user"); + CHECK(consentOf(requests[0]) == stateWith({"sessions"})); + CHECK(valueOf(requests[1], "begin_session") == "1"); + CHECK(valueOf(requests[1], "device_id") == "new-user"); + clearSDK(); +} + +TEST_CASE("consent: a device ID change without merge ends the running views under the old device ID") { + Countly &countly = freshInstance(); + countly.disableHealthCheck(); + countly.enableManualSessionControl(); + start(countly); + takeRequests(countly); + + countly.views().openView("Settings"); + countly.setDeviceID("new-user", false); + + std::vector requests = takeRequests(countly); + std::vector events = eventsIn(requests); + REQUIRE(events.size() == 2); + CHECK(events[1]["key"] == "[CLY]_view"); + CHECK(events[1]["segmentation"]["name"] == "Settings"); + CHECK(!events[1]["segmentation"].contains("visit")); + for (const HTTPCall &call : requests) { + CHECK(valueOf(call, "device_id") == COUNTLY_TEST_DEVICE_ID); + } + + countly.views().closeViewWithName("Settings"); + CHECK(countly.checkEQSize() == 0); + CHECK(logCode(countly, "w730") == 1); + clearSDK(); +} + +TEST_CASE("consent: a device ID change with merge keeps consent") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::EVENTS}); + start(countly); + takeRequests(countly); + + countly.setDeviceID("merged-user", true); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "old_device_id") == COUNTLY_TEST_DEVICE_ID); + CHECK(!has(requests[0], "consent")); + + CHECK(countly.getConsent(cly::consent::EVENTS)); + countly.addEvent(Event("click", 1)); + CHECK(countly.checkEQSize() == 1); + clearSDK(); +} + +TEST_CASE("consent: the server's consent requirement (cr)") { + SUBCASE("cr true turns the requirement on: the session ends and the server gets the state") { + Countly &countly = freshInstance(json({{"v", 2}, {"t", 1}, {"c", {{"cr", true}}}}), true); + ReleaseSettingsOnExit release; + start(countly); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "begin_session") == "1"); + countly.addEvent(Event("before", 1)); + CHECK(countly.getConsent(cly::consent::EVENTS)); + + settings_server.release(); + REQUIRE(waitUntil([&countly] { return countly.checkRQSize() == 3; })); + requests = takeRequests(countly); + REQUIRE(requests.size() == 3); + CHECK(valueOf(requests[0], "end_session") == "1"); + CHECK(consentOf(requests[1]) == stateWith({})); + CHECK(valueOf(requests[2], "location") == ""); + CHECK_FALSE(countly.getConsent(cly::consent::EVENTS)); + + countly.addEvent(Event("after", 1)); + countly.flushEvents(std::chrono::seconds(1)); + std::vector events = eventsIn(takeRequests(countly)); + REQUIRE(events.size() == 1); + CHECK(events[0]["key"] == "before"); + clearSDK(); + } + + SUBCASE("cr false lifts the requirement the app set, and no consent state is sent") { + Countly &countly = freshInstance(); + countly.setRequiresConsent(true); + std::string settings = json({{"cr", false}}).dump(); + countly.setSDKBehaviorSettings(settings); + start(countly); + + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "begin_session") == "1"); + CHECK(countly.getConsent(cly::consent::EVENTS)); + countly.addEvent(Event("click", 1)); + CHECK(countly.checkEQSize() == 1); + clearSDK(); + } + + SUBCASE("cr false arriving while running lifts the app's requirement: the session begins and no consent state is sent") { + Countly &countly = freshInstance(json({{"v", 2}, {"t", 1}, {"c", {{"cr", false}}}}), true); + ReleaseSettingsOnExit release; + countly.setRequiresConsent(true); + countly.enableRemoteConfig(); + start(countly); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 2); + CHECK(consentOf(requests[0]) == stateWith({})); + CHECK_FALSE(countly.getConsent(cly::consent::EVENTS)); + + settings_server.release(); + REQUIRE(waitUntil([] { return remoteConfigFetches() == 1; })); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + CHECK(remoteConfigFetches() == 1); + requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "begin_session") == "1"); + CHECK(countly.getConsent(cly::consent::EVENTS)); + clearSDK(); + } + + SUBCASE("cr leaving the settings brings back the app's requirement") { + Countly &countly = freshInstance(json({{"v", 2}, {"t", 1}, {"c", json::object()}}), true); + ReleaseSettingsOnExit release; + countly.setRequiresConsent(true); + std::string settings = json({{"cr", false}}).dump(); + countly.setSDKBehaviorSettings(settings); + start(countly); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "begin_session") == "1"); + + settings_server.release(); + REQUIRE(waitUntil([&countly] { return countly.checkRQSize() == 3; })); + requests = takeRequests(countly); + REQUIRE(requests.size() == 3); + CHECK(valueOf(requests[0], "end_session") == "1"); + CHECK(consentOf(requests[1]) == stateWith({})); + CHECK(valueOf(requests[2], "location") == ""); + clearSDK(); + } +} + +TEST_CASE("consent: calls that are ignored") { + SUBCASE("before start, consent calls are ignored and record w101") { + Countly &countly = freshInstance(); + countly.setRequiresConsent(true); + countly.giveConsent({cly::consent::SESSIONS}); + CHECK_FALSE(countly.getConsent(cly::consent::SESSIONS)); + CHECK(logCode(countly, "w101") == 2); + + start(countly); + std::vector requests = takeRequests(countly); + REQUIRE(!requests.empty()); + CHECK(consentOf(requests[0]) == stateWith({})); + clearSDK(); + } + + SUBCASE("while consent is not required, consent calls change nothing and send nothing") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + start(countly); + takeRequests(countly); + + countly.giveConsent({cly::consent::SESSIONS}); + countly.removeConsent({cly::consent::EVENTS}); + countly.removeConsentAll(); + CHECK(takeRequests(countly).empty()); + CHECK(countly.getConsent(cly::consent::EVENTS)); + countly.addEvent(Event("click", 1)); + CHECK(countly.checkEQSize() == 1); + clearSDK(); + } + + SUBCASE("unknown names and empty lists record w402; known names in the same call still count") { + Countly &countly = freshInstance(); + countly.disableHealthCheck(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + start(countly); + takeRequests(countly); + + countly.giveConsent({"bogus"}); + countly.giveConsent({}); + CHECK(takeRequests(countly).empty()); + + countly.giveConsent({cly::consent::SESSIONS, "bogus"}); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith({"sessions"})); + + CHECK_FALSE(countly.getConsent("bogus")); + CHECK(logCode(countly, "w402") == 4); + clearSDK(); + } +} + +TEST_CASE("consent: feature groups") { + SUBCASE("a group gives and removes all of its features at once") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + start(countly); + takeRequests(countly); + + countly.createFeatureGroup("activity", {cly::consent::SESSIONS, cly::consent::EVENTS, cly::consent::VIEWS}); + countly.setConsentFeatureGroup("activity", true); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith({"sessions", "events", "views"})); + CHECK(countly.getConsent("activity")); + + countly.removeConsent({cly::consent::VIEWS}); + takeRequests(countly); + CHECK_FALSE(countly.getConsent("activity")); + + countly.giveConsent({"activity"}); + requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith({"sessions", "events", "views"})); + + countly.setConsentFeatureGroup("activity", false); + requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith({})); + clearSDK(); + } + + SUBCASE("a group needs its own name and known features, and creating it again replaces it") { + Countly &countly = freshInstance(); + countly.disableHealthCheck(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + start(countly); + takeRequests(countly); + + countly.createFeatureGroup("sessions", {cly::consent::EVENTS}); + countly.createFeatureGroup("", {cly::consent::EVENTS}); + countly.createFeatureGroup("nothing", {}); + countly.createFeatureGroup("unknown", {"bogus"}); + countly.setConsentFeatureGroup("nothing", true); + CHECK(takeRequests(countly).empty()); + CHECK(logCode(countly, "w700") == 2); + CHECK(logCode(countly, "w402") == 3); + + countly.giveConsent({"sessions"}); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith({"sessions"})); + + countly.createFeatureGroup("pair", {cly::consent::EVENTS}); + countly.createFeatureGroup("pair", {cly::consent::VIEWS}); + countly.setConsentFeatureGroup("pair", true); + requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith({"sessions", "views"})); + clearSDK(); + } +} + +TEST_CASE("consent: metrics need the metrics consent") { + SUBCASE("without it, the session start and the health check carry no metrics") { + Countly &countly = freshInstance(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::SESSIONS}); + countly.setMetrics("Windows", "10", "PC", "1920x1080", "", "2.4.1"); + start(countly); + + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 2); + CHECK(valueOf(requests[1], "begin_session") == "1"); + CHECK(!has(requests[1], "metrics")); + REQUIRE(waitForHealthCheck()); + CHECK(!has(health_check_call_queue.front(), "metrics")); + clearSDK(); + } + + SUBCASE("with it, both carry their metrics") { + Countly &countly = freshInstance(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::SESSIONS, cly::consent::METRICS}); + countly.setMetrics("Windows", "10", "PC", "1920x1080", "", "2.4.1"); + start(countly); + + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 2); + REQUIRE(has(requests[1], "metrics")); + CHECK(json::parse(requests[1].data.at("metrics")) == json({{"_os", "Windows"}, {"_os_version", "10"}, {"_device", "PC"}, {"_resolution", "1920x1080"}, {"_app_version", "2.4.1"}})); + REQUIRE(waitForHealthCheck()); + HTTPCall healthCheck = health_check_call_queue.front(); + REQUIRE(has(healthCheck, "metrics")); + CHECK(json::parse(healthCheck.data.at("metrics")) == json({{"_app_version", "2.4.1"}})); + clearSDK(); + } +} + +TEST_CASE("consent: remote config needs the remote-config consent") { + SUBCASE("no download without it; giving it downloads once; the values stay after removal") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.enableRemoteConfig(); + start(countly); + takeRequests(countly); + + countly.updateRemoteConfig(); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + CHECK(remoteConfigFetches() == 0); + + countly.giveConsent({cly::consent::REMOTE_CONFIG}); + REQUIRE(waitUntil([] { return remoteConfigFetches() == 1; })); + REQUIRE(waitUntil([&countly] { return countly.getRemoteConfigValue("color") == "#FF9900"; })); + + countly.removeConsent({cly::consent::REMOTE_CONFIG}); + CHECK(countly.getRemoteConfigValue("color") == "#FF9900"); + countly.updateRemoteConfig(); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + CHECK(remoteConfigFetches() == 1); + clearSDK(); + } + + SUBCASE("giving sessions and remote-config together downloads once") { + Countly &countly = freshInstance(); + countly.setRequiresConsent(true); + countly.enableRemoteConfig(); + start(countly); + takeRequests(countly); + + countly.giveConsent({cly::consent::SESSIONS, cly::consent::REMOTE_CONFIG}); + REQUIRE(waitUntil([] { return remoteConfigFetches() >= 1; })); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + CHECK(remoteConfigFetches() == 1); + clearSDK(); + } +} + +TEST_CASE("consent: internal events need the consent of their own feature") { + const std::vector keys = {"custom", "[CLY]_unknown", "[CLY]_view", "[CLY]_nps", "[CLY]_survey", "[CLY]_star_rating", "[CLY]_orientation", "[CLY]_push_action"}; + + struct Case { + std::string name; + bool required; + std::vector given; + std::vector recorded; + }; + const std::vector cases = { + {"events covers custom keys and unknown internal keys", true, {cly::consent::EVENTS}, {"custom", "[CLY]_unknown"}}, + {"feedback covers NPS, survey and rating answers", true, {cly::consent::FEEDBACK}, {"[CLY]_nps", "[CLY]_survey", "[CLY]_star_rating"}}, + {"star-rating covers rating answers only", true, {cly::consent::STAR_RATING}, {"[CLY]_star_rating"}}, + {"views and users cover their own events", true, {cly::consent::VIEWS, cly::consent::USERS}, {"[CLY]_view", "[CLY]_orientation"}}, + {"without the requirement every key is recorded", false, {}, keys}, + }; + + for (const Case &testCase : cases) { + CAPTURE(testCase.name); + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(testCase.required); + countly.setConsentEnabled(testCase.given); + start(countly); + + for (const std::string &key : keys) { + countly.RecordEvent(key, 1); + } + CHECK(queuedEventKeys(countly) == testCase.recorded); + clearSDK(); + } +} + +TEST_CASE("consent: the health check counts only the calls of features with consent") { + SUBCASE("calls of features without consent are not counted") { + Countly &countly = freshInstance(); + countly.disableHealthCheck(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::EVENTS}); + start(countly); + + countly.beginSession(); + countly.updateSession(); + countly.endSession(); + countly.addEvent(Event("click", 1)); + countly.views().openView("main"); + countly.crash().addBreadcrumb("crumb"); + countly.crash().recordException("title", "trace", false, CRASH_METRICS); + + CHECK(countly.debugHealthCheck()->snapshot()["fu"] == json({{"events", {{"record", 1}}}})); + clearSDK(); + } + + SUBCASE("w101 of a call before start is counted only with the consent of its feature") { + Countly &countly = freshInstance(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::SESSIONS}); + countly.addEvent(Event("too early", 1)); + countly.beginSession(); + CHECK(logCode(countly, "w101") == 1); + clearSDK(); + } + + SUBCASE("w101 of a call before start is counted for every feature while consent is not required") { + Countly &countly = freshInstance(); + countly.addEvent(Event("too early", 1)); + countly.beginSession(); + CHECK(logCode(countly, "w101") == 2); + clearSDK(); + } +} + +TEST_CASE("consent: consent given before start") { + SUBCASE("every feature the SDK offers can be given at once") { + const std::vector offered = cly::consent::allFeatures(); + CHECK(std::set(offered.begin(), offered.end()) == std::set(ALL_FEATURES.begin(), ALL_FEATURES.end())); + + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled(offered); + start(countly); + + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith(ALL_FEATURES)); + clearSDK(); + } + + SUBCASE("unknown names are ignored with w402, the others are given") { + Countly &countly = freshInstance(); + countly.disableHealthCheck(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::SESSIONS, "bogus"}); + start(countly); + + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(consentOf(requests[0]) == stateWith({"sessions"})); + CHECK(logCode(countly, "w402") == 1); + clearSDK(); + } + + SUBCASE("a second setConsentEnabled replaces the first") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::SESSIONS}); + countly.setConsentEnabled({cly::consent::EVENTS}); + start(countly); + + std::vector requests = takeRequests(countly); + REQUIRE(!requests.empty()); + CHECK(consentOf(requests[0]) == stateWith({"events"})); + clearSDK(); + } + + SUBCASE("without the requirement, no consent state is sent") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setConsentEnabled({cly::consent::SESSIONS}); + start(countly); + CHECK(takeRequests(countly).empty()); + clearSDK(); + } + + SUBCASE("after start, the consent options are ignored") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + start(countly); + takeRequests(countly); + + countly.setConsentEnabled({cly::consent::EVENTS}); + countly.setRequiresConsent(false); + CHECK_FALSE(countly.getConsent(cly::consent::EVENTS)); + CHECK(takeRequests(countly).empty()); + clearSDK(); + } +} + +TEST_CASE("consent: a session start carries the empty location while location is off") { + SUBCASE("the server turned location tracking off") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + std::string settings = json({{"lt", false}}).dump(); + countly.setSDKBehaviorSettings(settings); + start(countly); + takeRequests(countly); + + REQUIRE(countly.beginSession()); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "location") == ""); + CHECK(!has(requests[0], "country_code")); + CHECK(!has(requests[0], "city")); + CHECK(!has(requests[0], "ip_address")); + clearSDK(); + } + + SUBCASE("while location is on, a session start carries no location parameter of its own") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + start(countly); + takeRequests(countly); + + REQUIRE(countly.beginSession()); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(!has(requests[0], "location")); + clearSDK(); + } + + SUBCASE("the location consent is missing") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::SESSIONS}); + start(countly); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(has(requests[0], "consent")); + + REQUIRE(countly.beginSession()); + requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "location") == ""); + clearSDK(); + } + + SUBCASE("with the location consent, the stored values go with the session start") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::SESSIONS, cly::consent::LOCATION}); + start(countly); + takeRequests(countly); + countly.setLocation("TR", "Istanbul", "", "203.0.113.7"); + takeRequests(countly); + + REQUIRE(countly.beginSession()); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 1); + CHECK(valueOf(requests[0], "country_code") == "TR"); + CHECK(valueOf(requests[0], "city") == "Istanbul"); + CHECK(valueOf(requests[0], "ip_address") == "203.0.113.7"); + CHECK(!has(requests[0], "location")); + clearSDK(); + } + + SUBCASE("required with nothing given and manual sessions, start ends with a location erase") { + Countly &countly = freshInstance(); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + start(countly); + std::vector requests = takeRequests(countly); + REQUIRE(requests.size() == 2); + CHECK(consentOf(requests[0]) == stateWith({})); + CHECK(valueOf(requests[1], "location") == ""); + clearSDK(); + } +} + +TEST_CASE("consent: each instance has its own consent state") { + clearSDK(); + settings_server.reset(); + remove("test-consent-a.db"); + remove("test-consent-b.db"); + ThreadSafeHTTPCallQueue callsA; + ThreadSafeHTTPCallQueue callsB; + { + Countly a; + Countly b; + a.setHTTPClient([&callsA](bool use_post, const std::string &url, const std::string &data) { return fakeSendHTTPInto(callsA, use_post, url, data); }); + b.setHTTPClient([&callsB](bool use_post, const std::string &url, const std::string &data) { return fakeSendHTTPInto(callsB, use_post, url, data); }); + a.setDeviceID(COUNTLY_TEST_DEVICE_ID); + b.setDeviceID(COUNTLY_TEST_DEVICE_ID); + a.SetPath("test-consent-a.db"); + b.SetPath("test-consent-b.db"); + a.enableManualSessionControl(); + b.enableManualSessionControl(); + a.disableSDKBehaviorSettingsUpdates(); + b.disableSDKBehaviorSettingsUpdates(); + a.setRequiresConsent(true); + a.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + b.start(COUNTLY_TEST_APP_KEY, COUNTLY_TEST_HOST, COUNTLY_TEST_PORT, false); + a.processRQDebug(); + b.processRQDebug(); + + auto consentRequests = [](ThreadSafeHTTPCallQueue &calls) { + size_t count = 0; + for (size_t index = 0; index < calls.size(); index++) { + if (has(calls.at(index), "consent")) { + count++; + } + } + return count; + }; + CHECK(consentRequests(callsA) == 1); + CHECK(consentRequests(callsB) == 0); + + a.giveConsent({cly::consent::VIEWS}); + CHECK_FALSE(a.getConsent(cly::consent::EVENTS)); + CHECK(b.getConsent(cly::consent::EVENTS)); + a.addEvent(Event("click", 1)); + b.addEvent(Event("click", 1)); + CHECK(a.checkEQSize() == 0); + CHECK(b.checkEQSize() == 1); + } + remove("test-consent-a.db"); + remove("test-consent-b.db"); +} + +TEST_CASE("consent: checkAllConsent logs every feature and changes nothing") { + Countly &countly = freshInstance(); + countly.setLogger(captureLog); + countly.enableManualSessionControl(); + countly.setRequiresConsent(true); + countly.setConsentEnabled({cly::consent::SESSIONS}); + start(countly); + takeRequests(countly); + + clearCapturedLogs(); + countly.checkAllConsent(); + for (const std::string &feature : ALL_FEATURES) { + CAPTURE(feature); + CHECK(capturedLogContains(feature)); + } + CHECK(takeRequests(countly).empty()); + CHECK(countly.getConsent(cly::consent::SESSIONS)); + clearSDK(); + clearCapturedLogs(); +}