From 1c0ca2758d6cf25439dd68072d1c62e25a95cfb1 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 2 Jul 2026 15:51:28 +0300 Subject: [PATCH] feat: theme mode to the journeys --- CHANGELOG.md | 3 + Countly.xcodeproj/project.pbxproj | 4 + CountlyConnectionManager.h | 1 + CountlyConnectionManager.m | 1 + CountlyContentBuilderInternal.m | 8 +- CountlyDeviceInfo.h | 14 ++++ CountlyDeviceInfo.m | 59 ++++++++++++++ CountlyFeedbackWidget.m | 5 +- CountlyFeedbacksInternal.m | 2 + CountlyTests/CountlyThemeTests.swift | 116 +++++++++++++++++++++++++++ 10 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 CountlyTests/CountlyThemeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba01a52..919f7077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Added support for reporting the app's current theme (light or dark) when presenting feedback widgets, rating widgets, and content, so they are displayed in matching conditions. + ## 26.1.2 * ! Minor breaking change ! Raised the minimum supported deployment target to iOS 12 and tvOS 12 (previously iOS 10 and tvOS 10) for compatibility with Xcode 26 and the iOS 26 SDK. Apps targeting iOS 10/11 or tvOS 10/11 are no longer supported. diff --git a/Countly.xcodeproj/project.pbxproj b/Countly.xcodeproj/project.pbxproj index 7c73122b..6482da36 100644 --- a/Countly.xcodeproj/project.pbxproj +++ b/Countly.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ 968426812BF2302C007B303E /* CountlyConnectionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */; }; 96CB10A12F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */; }; AB10000011112222333344A1 /* CountlyScreenMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10000011112222333344A0 /* CountlyScreenMetricsTests.swift */; }; + AB10000011112222333344B1 /* CountlyThemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB10000011112222333344B0 /* CountlyThemeTests.swift */; }; 9691B7652F1FA35500A6ADCB /* CountlyOverlayWindow.h in Headers */ = {isa = PBXBuildFile; fileRef = 9691B7642F1FA35500A6ADCB /* CountlyOverlayWindow.h */; }; 9691B7672F1FA35C00A6ADCB /* CountlyOverlayWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = 9691B7662F1FA35C00A6ADCB /* CountlyOverlayWindow.m */; }; 969E5BCE2ECC4D3200AB406A /* CountlyConsentManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969E5BCD2ECC4D2C00AB406A /* CountlyConsentManagerTests.swift */; }; @@ -222,6 +223,7 @@ 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyConnectionManagerTests.swift; sourceTree = ""; }; 96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyContentBuilderTests.swift; sourceTree = ""; }; AB10000011112222333344A0 /* CountlyScreenMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyScreenMetricsTests.swift; sourceTree = ""; }; + AB10000011112222333344B0 /* CountlyThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyThemeTests.swift; sourceTree = ""; }; 9691B7642F1FA35500A6ADCB /* CountlyOverlayWindow.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyOverlayWindow.h; sourceTree = ""; }; 9691B7662F1FA35C00A6ADCB /* CountlyOverlayWindow.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyOverlayWindow.m; sourceTree = ""; }; 969E5BCD2ECC4D2C00AB406A /* CountlyConsentManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyConsentManagerTests.swift; sourceTree = ""; }; @@ -279,6 +281,7 @@ 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */, 96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */, AB10000011112222333344A0 /* CountlyScreenMetricsTests.swift */, + AB10000011112222333344B0 /* CountlyThemeTests.swift */, 96E680412BFF89AC0091E105 /* CountlyCrashReporterTests.swift */, EBD1642D826B471A80175BE3 /* CountlyWebViewManagerTests.swift */, 265156CB5F59417EA14BAC2F /* CountlyWebViewManager+Tests.h */, @@ -562,6 +565,7 @@ 968426812BF2302C007B303E /* CountlyConnectionManagerTests.swift in Sources */, 96CB10A12F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift in Sources */, AB10000011112222333344A1 /* CountlyScreenMetricsTests.swift in Sources */, + AB10000011112222333344B1 /* CountlyThemeTests.swift in Sources */, 96329DE42D952F1500BFD641 /* MockURLProtocol.swift in Sources */, 96329DE02D9426F300BFD641 /* CountlyServerConfigTests.swift in Sources */, ABCDE001000000000000ABCD /* CountlyServerConfigValidationTests.swift in Sources */, diff --git a/CountlyConnectionManager.h b/CountlyConnectionManager.h index 00b07dd6..5a7d93a2 100644 --- a/CountlyConnectionManager.h +++ b/CountlyConnectionManager.h @@ -14,6 +14,7 @@ extern NSString* const kCountlyQSKeySDKVersion; extern NSString* const kCountlyQSKeySDKName; extern NSString* const kCountlyQSKeyMethod; extern NSString* const kCountlyQSKeyMetrics; +extern NSString* const kCountlyQSKeyTheme; extern NSString* const kCountlyEndpointI; extern NSString* const kCountlyEndpointO; diff --git a/CountlyConnectionManager.m b/CountlyConnectionManager.m index d1ef9ac8..40d52cbf 100644 --- a/CountlyConnectionManager.m +++ b/CountlyConnectionManager.m @@ -70,6 +70,7 @@ @interface CountlyConnectionManager () NSString* const kCountlyQSKeyRemainingRequest = @"rr"; NSString* const kCountlyQSKeyMethod = @"method"; +NSString* const kCountlyQSKeyTheme = @"th"; NSString* const kCountlyRCKeyABOptIn = @"ab"; NSString* const kCountlyRCKeyABOptOut = @"ab_opt_out"; diff --git a/CountlyContentBuilderInternal.m b/CountlyContentBuilderInternal.m index 9f8c07d3..80eb27a3 100644 --- a/CountlyContentBuilderInternal.m +++ b/CountlyContentBuilderInternal.m @@ -343,10 +343,14 @@ - (void)showContentWithHtmlPath:(NSString *)urlString placementCoordinates:(NSDi CGRect frame = CGRectMake(x, y, width, height); + // Append the current theme so the content renders matching the app (resolved on the main + // thread here, where the trait collection is safe to read). + NSURL *themedURL = [NSURL URLWithString:[CountlyDeviceInfo URLStringByAppendingThemeMode:urlString]] ?: url; + // Log the URL and the frame - CLY_LOG_I(@"%s showing content from URL: [%@], frame: [%@]", __FUNCTION__, url, NSStringFromCGRect(frame)); + CLY_LOG_I(@"%s showing content from URL: [%@], frame: [%@]", __FUNCTION__, themedURL, NSStringFromCGRect(frame)); CountlyWebViewManager* webViewManager = CountlyWebViewManager.new; - [webViewManager createWebViewWithURL:url frame:frame appearBlock:^ + [webViewManager createWebViewWithURL:themedURL frame:frame appearBlock:^ { CLY_LOG_I(@"%s webview should be appeared", __FUNCTION__); } dismissBlock:^ diff --git a/CountlyDeviceInfo.h b/CountlyDeviceInfo.h index 90d53d00..f0f43e21 100644 --- a/CountlyDeviceInfo.h +++ b/CountlyDeviceInfo.h @@ -51,4 +51,18 @@ typedef enum : NSUInteger + (BOOL)isJailbroken; + (BOOL)isInBackground; + (NSString *)architectureNameForCPUType:(cpu_type_t)cpuType subtype:(cpu_subtype_t)cpuSubtype; + +// Resolves the theme the app is currently rendering with. Returns @"d" for dark, @"l" for light, +// or nil when the mode is undefined/unavailable (e.g. below iOS 13). Reads the key window's trait +// collection because in-app messages render in that window - so its style is the effective theme. ++ (nullable NSString *)themeMode; + +// Appends the current theme as the "th" query parameter to the given URL, so a feedback/rating +// widget or content loaded in a WebView renders matching the theme. "?" is used when the URL has +// no query yet, "&" otherwise. When the theme is undefined the URL is returned unchanged. ++ (NSString *)URLStringByAppendingThemeMode:(NSString *)urlString NS_SWIFT_NAME(urlStringByAppendingThemeMode(_:)); + +// Primitive used by URLStringByAppendingThemeMode: with the resolved theme passed explicitly so +// the separator/omission logic is testable independently of the runtime interface style. ++ (NSString *)URLString:(NSString *)urlString byAppendingThemeMode:(nullable NSString *)theme; @end diff --git a/CountlyDeviceInfo.m b/CountlyDeviceInfo.m index 6ebc99f5..7dd6d6d3 100644 --- a/CountlyDeviceInfo.m +++ b/CountlyDeviceInfo.m @@ -311,6 +311,65 @@ + (NSString *)density return [NSString stringWithFormat:@"@%dx", (int)scale]; } ++ (NSString *)themeMode +{ +#if (TARGET_OS_IOS || TARGET_OS_TV) + @try + { + if (@available(iOS 12.0, tvOS 12.0, *)) + { + UITraitCollection *traits = nil; + + if (@available(iOS 13.0, tvOS 13.0, *)) + { + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) + { + if ([scene isKindOfClass:UIWindowScene.class]) + { + UIWindow *window = ((UIWindowScene *)scene).windows.firstObject; + if (window) + traits = window.traitCollection; + break; + } + } + } + + if (!traits) + traits = UIScreen.mainScreen.traitCollection; + + switch (traits.userInterfaceStyle) + { + case UIUserInterfaceStyleDark: + return @"d"; + case UIUserInterfaceStyleLight: + return @"l"; + default: + return nil; + } + } + } + @catch (NSException *exception) + { + CLY_LOG_W(@"%s, failed to resolve theme mode: %@", __FUNCTION__, exception); + } +#endif + return nil; +} + ++ (NSString *)URLStringByAppendingThemeMode:(NSString *)urlString +{ + return [self URLString:urlString byAppendingThemeMode:[self themeMode]]; +} + ++ (NSString *)URLString:(NSString *)urlString byAppendingThemeMode:(NSString *)theme +{ + if (!theme.length) + return urlString; + + NSString *separator = [urlString containsString:@"?"] ? @"&" : @"?"; + return [urlString stringByAppendingFormat:@"%@%@=%@", separator, kCountlyQSKeyTheme, theme]; +} + + (NSString *)locale { return NSLocale.currentLocale.localeIdentifier; diff --git a/CountlyFeedbackWidget.m b/CountlyFeedbackWidget.m index 861116e6..428c886a 100644 --- a/CountlyFeedbackWidget.m +++ b/CountlyFeedbackWidget.m @@ -336,8 +336,9 @@ - (NSURL *)generateWidgetURL { // Append the custom parameter to the URL [URL appendFormat:@"&custom=%@", customString.cly_URLEscaped]; } - - return [NSURL URLWithString:URL]; + + NSString *finalURL = [CountlyDeviceInfo URLStringByAppendingThemeMode:URL]; + return [NSURL URLWithString:finalURL]; } diff --git a/CountlyFeedbacksInternal.m b/CountlyFeedbacksInternal.m index f3215007..d804ffb2 100644 --- a/CountlyFeedbacksInternal.m +++ b/CountlyFeedbacksInternal.m @@ -377,6 +377,8 @@ - (NSURL *)widgetDisplayURL:(NSString *)widgetID kCountlyEndpointFeedback, queryString]; + URLString = [CountlyDeviceInfo URLStringByAppendingThemeMode:URLString]; + return [NSURL URLWithString:URLString]; } diff --git a/CountlyTests/CountlyThemeTests.swift b/CountlyTests/CountlyThemeTests.swift new file mode 100644 index 00000000..324a4e9b --- /dev/null +++ b/CountlyTests/CountlyThemeTests.swift @@ -0,0 +1,116 @@ +// +// CountlyThemeTests.swift +// CountlyTests +// +// Covers the theme ("th") reporting added to the URLs loaded into the WebView for feedback +// widgets, rating widgets, and content. The logic lives in CountlyDeviceInfo: +// - themeMode: resolves the app's current interface style to "d"/"l"/nil. +// - URLString(_:byAppendingThemeMode:): the pure separator/omission primitive. +// - URLStringByAppendingThemeMode(_:): the convenience that resolves themeMode and appends. +// +// The primitive is tested deterministically (theme passed explicitly). themeMode itself is +// environment-dependent, so it is tested for invariants plus, when a key window is available, +// driven deterministically through overrideUserInterfaceStyle. +// + +import XCTest +@testable import Countly + +#if os(iOS) +class CountlyThemeTests: CountlyBaseTestCase { + + // MARK: - URLString(_:byAppendingThemeMode:) — deterministic primitive + + /// The separator depends on whether the URL already has a query, and the resolved theme value + /// is echoed verbatim. Covers dark/light across both separator cases in one flow. + func test_appendThemeMode_appliesCorrectSeparatorAndValue() { + // no existing query -> "?" + XCTAssertEqual( + CountlyDeviceInfo.urlString("https://c.ly/feedback/nps", byAppendingThemeMode: "d"), + "https://c.ly/feedback/nps?th=d") + // existing query -> "&" + XCTAssertEqual( + CountlyDeviceInfo.urlString("https://c.ly/feedback/nps?widget_id=abc&app_key=k", byAppendingThemeMode: "d"), + "https://c.ly/feedback/nps?widget_id=abc&app_key=k&th=d") + // light value is echoed just the same + XCTAssertEqual( + CountlyDeviceInfo.urlString("https://c.ly/o/feedback/widget?a=1", byAppendingThemeMode: "l"), + "https://c.ly/o/feedback/widget?a=1&th=l") + } + + /// When the theme can not be resolved (nil) or is empty, the URL must be returned untouched - + /// no dangling "?th=" or "&th=". + func test_appendThemeMode_omittedWhenThemeUndefined() { + let withQuery = "https://c.ly/content?cid=1" + let withoutQuery = "https://c.ly/content" + + XCTAssertEqual(CountlyDeviceInfo.urlString(withQuery, byAppendingThemeMode: nil), withQuery) + XCTAssertEqual(CountlyDeviceInfo.urlString(withoutQuery, byAppendingThemeMode: nil), withoutQuery) + XCTAssertEqual(CountlyDeviceInfo.urlString(withQuery, byAppendingThemeMode: ""), withQuery) + } + + // MARK: - themeMode — invariants that hold in any test host + + /// themeMode must only ever return "d", "l", or nil, and the convenience appender must be + /// exactly equivalent to feeding themeMode into the primitive. + func test_themeMode_isConstrainedAndConvenienceIsConsistent() { + let mode = CountlyDeviceInfo.themeMode() + if let mode = mode { + XCTAssertTrue(mode == "d" || mode == "l", "themeMode must be d, l, or nil; got \(mode)") + } + + let url = "https://c.ly/feedback/rating?widget_id=abc" + XCTAssertEqual( + CountlyDeviceInfo.urlStringByAppendingThemeMode(url), + CountlyDeviceInfo.urlString(url, byAppendingThemeMode: mode), + "the convenience method must resolve themeMode and delegate to the primitive") + } + + // MARK: - themeMode — deterministic via window override (hosted test only) + + /// Forcing the key window's interface style must flip themeMode and, through it, the appended + /// parameter. Skipped when the test host has no key window (logic-test bundle). + func test_themeMode_reflectsWindowInterfaceStyleOverride() throws { + guard #available(iOS 13.0, *), let window = Self.firstKeyWindow() else { + throw XCTSkip("requires iOS 13+ and a hosted key window") + } + + let original = window.overrideUserInterfaceStyle + defer { + window.overrideUserInterfaceStyle = original + Self.settle() + } + + window.overrideUserInterfaceStyle = .dark + Self.settle() + XCTAssertEqual(CountlyDeviceInfo.themeMode(), "d") + XCTAssertEqual( + CountlyDeviceInfo.urlStringByAppendingThemeMode("https://c.ly/feedback/nps?widget_id=x"), + "https://c.ly/feedback/nps?widget_id=x&th=d") + + window.overrideUserInterfaceStyle = .light + Self.settle() + XCTAssertEqual(CountlyDeviceInfo.themeMode(), "l") + XCTAssertEqual( + CountlyDeviceInfo.urlStringByAppendingThemeMode("https://c.ly/content"), + "https://c.ly/content?th=l") + } + + // MARK: - helpers + + @available(iOS 13.0, *) + private static func firstKeyWindow() -> UIWindow? { + for scene in UIApplication.shared.connectedScenes { + if let windowScene = scene as? UIWindowScene { + return windowScene.windows.first + } + } + return nil + } + + /// Let UIKit propagate the trait-collection change before reading it back. + private static func settle() { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } +} +#endif