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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions Countly.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -222,6 +223,7 @@
968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyConnectionManagerTests.swift; sourceTree = "<group>"; };
96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyContentBuilderTests.swift; sourceTree = "<group>"; };
AB10000011112222333344A0 /* CountlyScreenMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyScreenMetricsTests.swift; sourceTree = "<group>"; };
AB10000011112222333344B0 /* CountlyThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyThemeTests.swift; sourceTree = "<group>"; };
9691B7642F1FA35500A6ADCB /* CountlyOverlayWindow.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyOverlayWindow.h; sourceTree = "<group>"; };
9691B7662F1FA35C00A6ADCB /* CountlyOverlayWindow.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyOverlayWindow.m; sourceTree = "<group>"; };
969E5BCD2ECC4D2C00AB406A /* CountlyConsentManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyConsentManagerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
1 change: 1 addition & 0 deletions CountlyConnectionManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions CountlyConnectionManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 6 additions & 2 deletions CountlyContentBuilderInternal.m
Original file line number Diff line number Diff line change
Expand Up @@ -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:^
Expand Down
14 changes: 14 additions & 0 deletions CountlyDeviceInfo.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions CountlyDeviceInfo.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions CountlyFeedbackWidget.m
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}


Expand Down
2 changes: 2 additions & 0 deletions CountlyFeedbacksInternal.m
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@ - (NSURL *)widgetDisplayURL:(NSString *)widgetID
kCountlyEndpointFeedback,
queryString];

URLString = [CountlyDeviceInfo URLStringByAppendingThemeMode:URLString];

return [NSURL URLWithString:URLString];
}

Expand Down
116 changes: 116 additions & 0 deletions CountlyTests/CountlyThemeTests.swift
Original file line number Diff line number Diff line change
@@ -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
Loading