From 68118a436210b69f8d26b1b5a73fe02f95be4dec Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 2 Jul 2026 15:49:06 +0300 Subject: [PATCH] refactor: improve content link comm --- CHANGELOG.md | 3 + CountlyTests/CountlyWebViewManagerTests.swift | 151 ++++++++++++++++++ CountlyWebViewManager.m | 142 ++++++++++++++-- 3 files changed, 282 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba01a52..81c6dfa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Improved link handling for content and feedback widgets, so links that carry their own query parameters, such as deep links, are parsed correctly. + ## 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/CountlyTests/CountlyWebViewManagerTests.swift b/CountlyTests/CountlyWebViewManagerTests.swift index 9d8c7cd0..3033e23c 100644 --- a/CountlyTests/CountlyWebViewManagerTests.swift +++ b/CountlyTests/CountlyWebViewManagerTests.swift @@ -73,6 +73,157 @@ class CountlyWebViewManagerTests: XCTestCase { XCTAssertTrue(result.isEmpty) } + // MARK: - link query-param preservation (backward-validating span) + + func testParseQueryString_linkWithSingleQueryParam_preserved() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/path?foo=bar" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?foo=bar") + XCTAssertEqual(result["action"] as? String, "link") + } + + func testParseQueryString_linkWithMultipleQueryParams_preserved() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/path?foo=bar&baz=qux&n=42" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?foo=bar&baz=qux&n=42") + XCTAssertNil(result["baz"]) + XCTAssertNil(result["n"]) + } + + func testParseQueryString_deeplinkWithQueryParams_preserved() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=myapp://open?screen=home&id=42&ref=push" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "myapp://open?screen=home&id=42&ref=push") + } + + func testParseQueryString_linkWithoutQueryParams_preserved() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/landing" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/landing") + } + + func testParseQueryString_eventAfterLink_separatedFromLink() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com/p?a=b&c=d&event=[{\"key\":\"e\",\"sg\":{\"x\":\"y\"}}]" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com/p?a=b&c=d") + XCTAssertEqual(result["event"] as? String, "[{\"key\":\"e\",\"sg\":{\"x\":\"y\"}}]") + } + + func testParseQueryString_invalidReservedMarkerInLink_staysInLink() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com/p?a=b&event=notjson" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com/p?a=b&event=notjson") + XCTAssertNil(result["event"]) + } + + func testParseQueryString_eventJsonContainingReservedText_parsedWhole() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=event&event=[{\"key\":\"k\",\"sg\":{\"u\":\"a&close=1\"}}]" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["event"] as? String, "[{\"key\":\"k\",\"sg\":{\"u\":\"a&close=1\"}}]") + XCTAssertNil(result["close"]) + } + + func testParseQueryString_closeBeforeLink_separated() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&close=1&link=https://example.com/path?foo=bar&baz=qux" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?foo=bar&baz=qux") + XCTAssertEqual(result["close"] as? String, "1") + } + + func testParseQueryString_linkWithTrailingClose_separated() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/path?foo=bar&baz=qux&close=1" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?foo=bar&baz=qux") + XCTAssertEqual(result["close"] as? String, "1") + } + + func testParseQueryString_linkWithTrailingCloseZero_separated() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/path?a=1&b=2&close=0" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?a=1&b=2") + XCTAssertEqual(result["close"] as? String, "0") + } + + func testParseQueryString_linkEndingInReservedClose_consumedAsFlag() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com?a=b&c=d&close=1&close=1" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com?a=b&c=d") + XCTAssertEqual(result["close"] as? String, "1") + } + + func testParseQueryString_encodedEventValue_decodedAndAvailable() { + // Encoded on the wire: [{"key":"test_key","sg":{"color":"blue"}}] — parseQueryString decodes. + let url = "https://countly_action_event/?cly_x_action_event=1&action=event&event=%5B%7B%22key%22%3A%22test_key%22%2C%22sg%22%3A%7B%22color%22%3A%22blue%22%7D%7D%5D" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["action"] as? String, "event") + XCTAssertEqual(result["event"] as? String, "[{\"key\":\"test_key\",\"sg\":{\"color\":\"blue\"}}]") + } + + func testParseQueryString_linkWithFragment_preserved() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/path?a=b#section" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/path?a=b#section") + } + + func testParseQueryString_linkWithRepeatedQuestionMark_preserved() { + // Regression guard: the old parser used componentsSeparatedByString:"?"[1] and dropped + // everything after the link's own "?". + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://example.com/p?a=b?c=d" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://example.com/p?a=b?c=d") + } + + func testParseQueryString_linkEventAndClose_allSeparated() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com/p?a=b&c=d&event=[{\"key\":\"e\"}]&close=1" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com/p?a=b&c=d") + XCTAssertEqual(result["close"] as? String, "1") + XCTAssertEqual(result["event"] as? String, "[{\"key\":\"e\"}]") + } + + func testParseQueryString_invalidCloseValue_staysInLink() { + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com/p?a=b&close=2" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com/p?a=b&close=2") + XCTAssertNil(result["close"]) + } + + func testParseQueryString_schemelessLink_fallbackTruncates() { + // A schemeless link fails link validation (no URI scheme), so the query falls back to the + // plain '&' split, which truncates a multi-param link. The server always prepends "https://", + // so this is an edge case; the test pins the current behavior. + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=example.com/p?a=b&c=d" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "example.com/p?a=b") + XCTAssertEqual(result["c"] as? String, "d") + } + + func testParseQueryString_linkWithPlus_preservesPlus() { + // Characterization: stringByRemovingPercentEncoding leaves a literal '+' untouched, so the + // link keeps its '+'. This differs from Android (URLDecoder decodes '+' to a space). + let url = "https://countly_action_event/?cly_x_action_event=1&action=link&link=https://x.com/search?q=a+b&lang=en" + let result = manager.parseQueryString(url)! + + XCTAssertEqual(result["link"] as? String, "https://x.com/search?q=a+b&lang=en") + } + // MARK: - notifyPageLoaded tests func testNotifyPageLoaded_callsAppearBlock() { diff --git a/CountlyWebViewManager.m b/CountlyWebViewManager.m index 45b892c4..f98d7f1b 100644 --- a/CountlyWebViewManager.m +++ b/CountlyWebViewManager.m @@ -422,29 +422,137 @@ - (void)widgetURLAction:(NSDictionary *)queryParameters { // none action yet } +// The action URL is percent-decoded once here (a malformed escape falls back to the raw string so +// the action is not dropped). Two params can carry a literal '&' in their value: "link" (sent +// unencoded, may hold its own query string) and a decoded "event"/"resize_me" JSON (segmentation +// strings can contain '&'). A plain '&' split would therefore mis-slice them, and the params can +// appear in any order. Instead we span the query from the END: at each step we take the right-most +// reserved marker ("&=") whose value VALIDATES for that key (event/resize_me = JSON, close = +// 0/1, action = a known verb, link = has a URI scheme), record it, and shrink the span to its left. +// A marker whose value does NOT validate is treated as ordinary text inside an enclosing value (so +// it is skipped and absorbed by an outer param). What remains at the front is the comm-url-adjacent +// identifier ("cly_x_action_event=1" / "cly_widget_command=1"), parsed verbatim. Reserved-name +// limitation (documented for integrators): a value that literally contains "&=" +// may be mis-split. - (NSDictionary *)parseQueryString:(NSString *)url { NSMutableDictionary *queryDict = [NSMutableDictionary dictionary]; - NSArray *urlComponents = [url componentsSeparatedByString:@"?"]; - if (urlComponents.count > 1) { - NSArray *queryItems = [urlComponents[1] componentsSeparatedByString:@"&"]; - - for (NSString *item in queryItems) { - NSArray *keyValue = [item componentsSeparatedByString:@"="]; - if (keyValue.count == 2) { - NSString *key = keyValue[0]; - NSString *value = keyValue[1]; - queryDict[key] = value; + NSString *decodedUrl = [url stringByRemovingPercentEncoding] ?: url; + + NSRange qMark = [decodedUrl rangeOfString:@"?"]; + if (qMark.location == NSNotFound) { + return queryDict; + } + NSString *query = [decodedUrl substringFromIndex:qMark.location + 1]; + + NSArray *reservedKeys = @[@"action", @"event", @"resize_me", @"close", @"link"]; + NSInteger end = (NSInteger)query.length; + + while (end > 0) { + NSInteger chosenIdx = -1; + NSString *chosenKey = nil; + NSString *chosenValue = nil; + + // Right-to-left, pick the first reserved marker whose value validates. Scanning from the + // right lets an inner (invalid) marker be absorbed into an outer, valid value. + NSInteger searchFrom = end; + while (searchFrom > 0) { + NSInteger marker = -1; + NSString *markerKey = nil; + for (NSString *key in reservedKeys) { + NSString *needle = [NSString stringWithFormat:@"&%@=", key]; + NSRange r = [query rangeOfString:needle options:NSBackwardsSearch range:NSMakeRange(0, searchFrom)]; + if (r.location != NSNotFound && (NSInteger)r.location > marker && (NSInteger)(r.location + needle.length) <= end) { + marker = (NSInteger)r.location; + markerKey = key; + } } + if (marker < 0) { + break; + } + NSInteger valueStart = marker + (NSInteger)markerKey.length + 2; // "&" + key + "=" + NSString *value = [query substringWithRange:NSMakeRange(valueStart, end - valueStart)]; + if ([self isReservedValue:value validForKey:markerKey]) { + chosenIdx = marker; + chosenKey = markerKey; + chosenValue = value; + break; + } + // Not a real param -> ordinary text; keep looking further left. + searchFrom = marker; + } + + if (chosenIdx < 0) { + break; + } + queryDict[chosenKey] = chosenValue; + end = chosenIdx; + } + + // Remaining prefix is the identifier param(s) adjacent to the comm URL. + NSString *head = [query substringToIndex:end]; + for (NSString *pair in [head componentsSeparatedByString:@"&"]) { + NSRange eq = [pair rangeOfString:@"="]; + if (eq.location != NSNotFound) { + queryDict[[pair substringToIndex:eq.location]] = [pair substringFromIndex:eq.location + 1]; } } return queryDict; } +// Validates a reserved param value. Returns NO if it does not validate (meaning the "&=" was +// actually text inside an enclosing value, not a real parameter). +- (BOOL)isReservedValue:(NSString *)value validForKey:(NSString *)key { + if ([key isEqualToString:@"event"]) { + return [self isValidJSONOfClass:[NSArray class] string:value]; + } else if ([key isEqualToString:@"resize_me"]) { + return [self isValidJSONOfClass:[NSDictionary class] string:value]; + } else if ([key isEqualToString:@"close"]) { + return [value isEqualToString:@"0"] || [value isEqualToString:@"1"]; + } else if ([key isEqualToString:@"action"]) { + return [value isEqualToString:@"event"] || [value isEqualToString:@"link"] || [value isEqualToString:@"resize_me"]; + } else if ([key isEqualToString:@"link"]) { + return [self hasURIScheme:value]; + } + return NO; +} + +- (BOOL)isValidJSONOfClass:(Class)klass string:(NSString *)value { + NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding]; + if (!data) { + return NO; + } + id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [obj isKindOfClass:klass]; +} + +// YES if value begins with a URI scheme (ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"), covering +// http(s) URLs and custom-scheme deeplinks. The server prepends "https://" to schemeless links, so +// a valid link value always carries a scheme. +- (BOOL)hasURIScheme:(NSString *)value { + NSRange colon = [value rangeOfString:@":"]; + if (colon.location == NSNotFound || colon.location == 0) { + return NO; + } + for (NSUInteger i = 0; i < colon.location; i++) { + unichar c = [value characterAtIndex:i]; + BOOL ok; + if (i == 0) { + ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } else { + ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.'; + } + if (!ok) { + return NO; + } + } + return YES; +} + - (void)recordEventsWithJSONString:(NSString *)jsonString { - // Decode the URL-encoded JSON string - NSString *decodedString = [jsonString stringByRemovingPercentEncoding]; + // The value is already percent-decoded by parseQueryString; use it directly. + NSString *decodedString = jsonString; // Convert the decoded string to NSData NSData *data = [decodedString dataUsingEncoding:NSUTF8StringEncoding]; @@ -487,6 +595,12 @@ - (void)recordEventsWithJSONString:(NSString *)jsonString { - (void)openExternalLink:(NSString *)urlString { NSURL *url = [NSURL URLWithString:urlString]; + if (!url) { + // The decoded link may contain characters NSURL rejects (e.g. a space from a decoded '%20'). + // Re-encode the illegal characters while preserving URL structure, then retry. + NSString *encoded = [urlString stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLFragmentAllowedCharacterSet]]; + url = encoded ? [NSURL URLWithString:encoded] : nil; + } if (url) { [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) { if (success) { @@ -500,8 +614,8 @@ - (void)openExternalLink:(NSString *)urlString { - (void)resizeWebViewWithJSONString:(NSString *)jsonString { - // Decode the URL-encoded JSON string - NSString *decodedString = [jsonString stringByRemovingPercentEncoding]; + // The value is already percent-decoded by parseQueryString; use it directly. + NSString *decodedString = jsonString; // Convert the decoded string to NSData NSData *data = [decodedString dataUsingEncoding:NSUTF8StringEncoding];