diff --git a/CHANGELOG.md b/CHANGELOG.md index 56816316..e1cbc7b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +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. + * Mitigated an issue where feedback widgets could be closed automatically after about a minute even while the user was still interacting with them. ## 26.1.3 diff --git a/Countly.xcodeproj/project.pbxproj b/Countly.xcodeproj/project.pbxproj index f3775586..7f6aafc2 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 db771855..3c7bccdb 100644 --- a/CountlyContentBuilderInternal.m +++ b/CountlyContentBuilderInternal.m @@ -417,10 +417,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