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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
151 changes: 151 additions & 0 deletions CountlyTests/CountlyWebViewManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
142 changes: 128 additions & 14 deletions CountlyWebViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -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 ("&<key>=") 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 "&<key>=<valid-value>"
// 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<NSString *> *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 "&<key>=" 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];
Expand Down Expand Up @@ -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) {
Expand All @@ -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];
Expand Down
Loading