From 36ebbc6b9cb7ff8ec81d761f191ace6236f00082 Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Tue, 7 Jun 2022 16:05:47 +0300 Subject: [PATCH] Update UniversalLink class to parse path and query params --- Riot/SupportingFiles/Riot-Bridging-Header.h | 1 + Riot/Utils/UniversalLink.h | 20 ++- Riot/Utils/UniversalLink.m | 136 +++++++++++++++++++- RiotTests/UniversalLinkTests.swift | 98 ++++++++++++++ 4 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 RiotTests/UniversalLinkTests.swift diff --git a/Riot/SupportingFiles/Riot-Bridging-Header.h b/Riot/SupportingFiles/Riot-Bridging-Header.h index 1de9b0dc7..3b4e282b7 100644 --- a/Riot/SupportingFiles/Riot-Bridging-Header.h +++ b/Riot/SupportingFiles/Riot-Bridging-Header.h @@ -52,6 +52,7 @@ #import "BubbleRoomTimelineCellProvider.h" #import "RoomSelectedStickerBubbleCell.h" #import "MXRoom+Riot.h" +#import "UniversalLink.h" // MatrixKit common imports, shared with all targets #import "MatrixKit-Bridging-Header.h" diff --git a/Riot/Utils/UniversalLink.h b/Riot/Utils/UniversalLink.h index df546a936..bb7c62952 100644 --- a/Riot/Utils/UniversalLink.h +++ b/Riot/Utils/UniversalLink.h @@ -18,17 +18,27 @@ NS_ASSUME_NONNULL_BEGIN -@interface UniversalLink : NSObject +@interface UniversalLink : NSObject +/// Original url @property (nonatomic, copy, readonly) NSURL *url; +/// Path params from the link. @property (nonatomic, copy, readonly) NSArray *pathParams; -@property (nonatomic, copy, readonly) NSDictionary *queryParams; +/// Query params from the link. Does not conform to RFC 1808. Designed for simplicity. +@property (nonatomic, copy, readonly) NSDictionary *queryParams; -- (id)initWithUrl:(NSURL *)url - pathParams:(NSArray *)pathParams - queryParams:(NSDictionary *)queryParams; +/// Homeserver url in the link if any +@property (nonatomic, copy, readonly, nullable) NSString *homeserverUrl; +/// Identity server url in the link if any +@property (nonatomic, copy, readonly, nullable) NSString *identityServerUrl; +/// via parameters url in the link if any +@property (nonatomic, copy, readonly) NSArray *via; + +/// Initializer +/// @param url original url +- (id)initWithUrl:(NSURL *)url; @end diff --git a/Riot/Utils/UniversalLink.m b/Riot/Utils/UniversalLink.m index 81bb453b4..cfdf08f59 100644 --- a/Riot/Utils/UniversalLink.m +++ b/Riot/Utils/UniversalLink.m @@ -15,21 +15,134 @@ */ #import "UniversalLink.h" +#import "NSArray+Element.h" @implementation UniversalLink -- (id)initWithUrl:(NSURL *)url pathParams:(NSArray *)pathParams queryParams:(NSDictionary *)queryParams +- (id)initWithUrl:(NSURL *)url { self = [super init]; if (self) { _url = url; - _pathParams = pathParams; - _queryParams = queryParams; + + // Extract required parameters from the link + [self parsePathAndQueryParams]; } return self; } +/** + Extract params from the URL fragment part (after '#') of a vector.im Universal link: + + The fragment can contain a '?'. So there are two kinds of parameters: path params and query params. + It is in the form of /[pathParam1]/[pathParam2]?[queryParam1Key]=[queryParam1Value]&[queryParam2Key]=[queryParam2Value] + */ +- (void)parsePathAndQueryParams +{ + NSArray *pathParams; + NSMutableDictionary *queryParams = [NSMutableDictionary dictionary]; + + NSArray *fragments = [_url.fragment componentsSeparatedByString:@"?"]; + + // Extract path params + pathParams = [fragments[0] componentsSeparatedByString:@"/"]; + + // Remove the first empty path param string + pathParams = [pathParams filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"length > 0"]]; + + // URL decode each path param + pathParams = [pathParams vc_map:^id _Nonnull(NSString * _Nonnull item) { + return [item stringByRemovingPercentEncoding]; + }]; + + // Extract query params + NSURLComponents *components = [NSURLComponents componentsWithURL:_url resolvingAgainstBaseURL:NO]; + for (NSURLQueryItem *item in components.queryItems) + { + if (item.value) + { + NSString *key = item.name; + NSString *value = item.value; + value = [value stringByReplacingOccurrencesOfString:@"+" withString:@" "]; + value = [value stringByRemovingPercentEncoding]; + + if ([key isEqualToString:@"via"]) + { + // Special case the via parameter + // As we can have several of them, store each value into an array + if (!queryParams[key]) + { + queryParams[key] = [NSMutableArray array]; + } + + [queryParams[key] addObject:value]; + } + else + { + queryParams[key] = value; + } + } + } + // Query params are in the form [queryParam1Key]=[queryParam1Value], so the + // presence of at least one '=' character is mandatory + if (fragments.count == 2 && (NSNotFound != [fragments[1] rangeOfString:@"="].location)) + { + for (NSString *keyValue in [fragments[1] componentsSeparatedByString:@"&"]) + { + // Get the parameter name + NSString *key = [keyValue componentsSeparatedByString:@"="][0]; + + // Get the parameter value + NSString *value = [keyValue componentsSeparatedByString:@"="][1]; + if (value.length) + { + value = [value stringByReplacingOccurrencesOfString:@"+" withString:@" "]; + value = [value stringByRemovingPercentEncoding]; + + if ([key isEqualToString:@"via"]) + { + // Special case the via parameter + // As we can have several of them, store each value into an array + if (!queryParams[key]) + { + queryParams[key] = [NSMutableArray array]; + } + + [queryParams[key] addObject:value]; + } + else + { + queryParams[key] = value; + } + } + } + } + + _pathParams = pathParams; + _queryParams = queryParams; +} + +- (NSString *)homeserverUrl +{ + return _queryParams[@"hs_url"]; +} + +- (NSString *)identityServerUrl +{ + return _queryParams[@"is_url"]; +} + +- (NSArray *)via +{ + NSArray *result = _queryParams[@"via"]; + if (!result) + { + return @[]; + } + return result; +} + - (BOOL)isEqual:(id)other { if (other == self) @@ -57,4 +170,21 @@ return result; } +- (NSString *)description +{ + return [NSString stringWithFormat:@"", _url.absoluteString]; +} + +#pragma mark - NSCopying +- (id)copyWithZone:(NSZone *)zone +{ + UniversalLink *link = [[self.class allocWithZone:zone] init]; + + link->_url = [_url copyWithZone:zone]; + link->_pathParams = [_pathParams copyWithZone:zone]; + link->_queryParams = [_queryParams copyWithZone:zone]; + + return link; +} + @end diff --git a/RiotTests/UniversalLinkTests.swift b/RiotTests/UniversalLinkTests.swift new file mode 100644 index 000000000..3f856046a --- /dev/null +++ b/RiotTests/UniversalLinkTests.swift @@ -0,0 +1,98 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Riot + +class UniversalLinkTests: XCTestCase { + + enum UniversalLinkTestError: Error { + case invalidURL + } + + func testInitialization() throws { + guard let url = URL(string: "https://example.com") else { + throw UniversalLinkTestError.invalidURL + } + let universalLink = UniversalLink(url: url) + XCTAssertEqual(universalLink.url, url) + XCTAssertTrue(universalLink.pathParams.isEmpty) + XCTAssertTrue(universalLink.queryParams.isEmpty) + } + + func testRegistrationLink() throws { + guard let url = URL(string: "https://app.element.io/#/register/?hs_url=matrix.example.com&is_url=identity.example.com") else { + throw UniversalLinkTestError.invalidURL + } + let universalLink = UniversalLink(url: url) + XCTAssertEqual(universalLink.url, url) + XCTAssertEqual(universalLink.pathParams.count, 1) + XCTAssertEqual(universalLink.pathParams.first, "register") + XCTAssertEqual(universalLink.queryParams.count, 2) + XCTAssertEqual(universalLink.homeserverUrl, "matrix.example.com") + XCTAssertEqual(universalLink.identityServerUrl, "identity.example.com") + } + + func testLoginLink() throws { + guard let url = URL(string: "https://mobile.element.io/?hs_url=matrix.example.com&is_url=identity.example.com") else { + throw UniversalLinkTestError.invalidURL + } + let universalLink = UniversalLink(url: url) + XCTAssertEqual(universalLink.url, url) + XCTAssertTrue(universalLink.pathParams.isEmpty) + XCTAssertEqual(universalLink.queryParams.count, 2) + XCTAssertEqual(universalLink.homeserverUrl, "matrix.example.com") + XCTAssertEqual(universalLink.identityServerUrl, "identity.example.com") + } + + func testPathParams() throws { + guard let url = URL(string: "https://mobile.element.io/#/param1/param2/param3?hs_url=matrix.example.com&is_url=identity.example.com") else { + throw UniversalLinkTestError.invalidURL + } + let universalLink = UniversalLink(url: url) + XCTAssertEqual(universalLink.url, url) + XCTAssertEqual(universalLink.pathParams.count, 3) + XCTAssertEqual(universalLink.pathParams[0], "param1") + XCTAssertEqual(universalLink.pathParams[1], "param2") + XCTAssertEqual(universalLink.pathParams[2], "param3") + XCTAssertEqual(universalLink.queryParams.count, 2) + XCTAssertEqual(universalLink.homeserverUrl, "matrix.example.com") + XCTAssertEqual(universalLink.identityServerUrl, "identity.example.com") + } + + func testVia() throws { + guard let url = URL(string: "https://mobile.element.io/?hs_url=matrix.example.com&is_url=identity.example.com&via=param1&via=param2") else { + throw UniversalLinkTestError.invalidURL + } + let universalLink = UniversalLink(url: url) + XCTAssertEqual(universalLink.url, url) + XCTAssertEqual(universalLink.queryParams.count, 3) + XCTAssertEqual(universalLink.homeserverUrl, "matrix.example.com") + XCTAssertEqual(universalLink.identityServerUrl, "identity.example.com") + XCTAssertEqual(universalLink.via, ["param1", "param2"]) + } + + func testDescription() throws { + let str = "https://mobile.element.io/?hs_url=matrix.example.com&is_url=identity.example.com&via=param1&via=param2" + guard let url = URL(string: str) else { + throw UniversalLinkTestError.invalidURL + } + let universalLink = UniversalLink(url: url) + let desc = String(format: "", str) + XCTAssertEqual(universalLink.description, desc) + } + +}