diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a3897d7fc..9aa8b88d4 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -101,6 +101,7 @@ 1FEC0A4EC6E6DF693C16B32A /* StringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */; }; 206F0DBAB6AF042CA1FF2C0D /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */; }; 208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; }; + 20C16A3F718802B0E4A19C83 /* URLComponentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76310030C831D4610A705603 /* URLComponentsTests.swift */; }; 2185C1F6724C78FFF355D6FA /* WelcomeScreenScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB10FA6570DD08B3966C159 /* WelcomeScreenScreenUITests.swift */; }; 21BF2B7CEDFE3CA67C5355AD /* test_image.png in Resources */ = {isa = PBXBuildFile; fileRef = C733D11B421CFE3A657EF230 /* test_image.png */; }; 22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; }; @@ -348,6 +349,7 @@ 6F2AB43A1EFAD8A97AF41A15 /* AnalyticsEvents in Frameworks */ = {isa = PBXBuildFile; productRef = 2A3F7BCCB18C15B30CCA39A9 /* AnalyticsEvents */; }; 6F2D5D4F2590310DFAE973E4 /* WaitingDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D698BFD68B061350553930 /* WaitingDialog.swift */; }; 6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF7BF82A950B91BC5469E91 /* ViewFrameReader.swift */; }; + 6FD8053301C5FEFA82D2F246 /* URLComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BFDCA5A09EE70BC17F2EFA7 /* URLComponents.swift */; }; 6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */; }; 70394ECD2DCC70741538620D /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BB8DDE245ED86C489BA983 /* AccessibilityIdentifiers.swift */; }; 70558528EF68CAAEF09972D5 /* RoomTimelineItemFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96ED747FF90332EA1333C22 /* RoomTimelineItemFixtures.swift */; }; @@ -1023,6 +1025,7 @@ 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineTests.swift; sourceTree = ""; }; 2AF715D4FD4710EBB637D661 /* SettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModelProtocol.swift; sourceTree = ""; }; 2BB385E148DE55C85C0A02D6 /* SoftLogoutScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenModels.swift; sourceTree = ""; }; + 2BFDCA5A09EE70BC17F2EFA7 /* URLComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponents.swift; sourceTree = ""; }; 2C0197EAE9D45A662B8847B6 /* RoomTimelineControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineControllerProtocol.swift; sourceTree = ""; }; 2CA028DCD4157F9A1F999827 /* BackgroundTaskProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskProtocol.swift; sourceTree = ""; }; 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringTests.swift; sourceTree = ""; }; @@ -1243,6 +1246,7 @@ 752A0EB49BF5BCEA37EDF7A3 /* Signposter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signposter.swift; sourceTree = ""; }; 75697AB5E64A12F1F069F511 /* EncryptedHistoryRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedHistoryRoomTimelineView.swift; sourceTree = ""; }; 75910F5A36EA8FF9BAD08D18 /* MigrationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationScreenUITests.swift; sourceTree = ""; }; + 76310030C831D4610A705603 /* URLComponentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsTests.swift; sourceTree = ""; }; 772334731A8BF8E6D90B194D /* LocationRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineView.swift; sourceTree = ""; }; 7773CBFDBD458E0B7E270507 /* PillView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillView.swift; sourceTree = ""; }; 780258F1B9D15E30549FF4BE /* NotificationSettingsEditScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModel.swift; sourceTree = ""; }; @@ -2323,6 +2327,7 @@ 287FC98AF2664EAD79C0D902 /* UIDevice.swift */, BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */, 227AC5D71A4CE43512062243 /* URL.swift */, + 2BFDCA5A09EE70BC17F2EFA7 /* URLComponents.swift */, AE40D4A5DD857AC16EED945A /* URLSession.swift */, 897DF5E9A70CE05A632FC8AF /* UTType.swift */, E992D7B8BE54B2AB454613AF /* XCUIElement.swift */, @@ -2747,6 +2752,7 @@ 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */, 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */, 1734A445A58ED855B977A0A8 /* TracingConfigurationTests.swift */, + 76310030C831D4610A705603 /* URLComponentsTests.swift */, EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */, 2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */, BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */, @@ -4535,6 +4541,7 @@ 282A5F3375DDC774AE09B0C3 /* TracingConfigurationTests.swift in Sources */, 8E650379587C31D7912ED67B /* UNNotification+Creator.swift in Sources */, AF33B9044498211C3D82F1E1 /* UNTextInputNotificationResponse+Creator.swift in Sources */, + 20C16A3F718802B0E4A19C83 /* URLComponentsTests.swift in Sources */, 8D3E1FADD78E72504DE0E402 /* UserAgentBuilderTests.swift in Sources */, E313BDD2B8813144139B2E00 /* UserDiscoveryServiceTest.swift in Sources */, A1DF0E1E526A981ED6D5DF44 /* UserIndicatorControllerTests.swift in Sources */, @@ -5067,6 +5074,7 @@ 245F7FE5961BD10C145A26E0 /* UITimelineView.swift in Sources */, D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */, 071A017E415AD378F2961B11 /* URL.swift in Sources */, + 6FD8053301C5FEFA82D2F246 /* URLComponents.swift in Sources */, 90733645AE76FB33DAD28C2B /* URLSession.swift in Sources */, 18867F4F1C8991EEC56EA932 /* UTType.swift in Sources */, 84226AD2E1F1FBC965F3B09E /* UnitTestsAppCoordinator.swift in Sources */, diff --git a/ElementX/Sources/Other/Extensions/URLComponents.swift b/ElementX/Sources/Other/Extensions/URLComponents.swift new file mode 100644 index 000000000..d0ffdc33b --- /dev/null +++ b/ElementX/Sources/Other/Extensions/URLComponents.swift @@ -0,0 +1,59 @@ +// +// Copyright 2023 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 Foundation + +extension URLComponents { + var fragmentQueryItems: [URLQueryItem]? { + get { + guard let fragment, + let fragmentQuery = fragment.components(separatedBy: "?").last else { + return nil + } + + var fragmentComponents = URLComponents() + fragmentComponents.query = fragmentQuery + + return fragmentComponents.queryItems + } + + set { + var fragmentComponents = URLComponents() + fragmentComponents.queryItems = newValue + + guard let fragmentQuery = fragmentComponents.query else { + MXLog.error("Failed building fragment query") + return + } + + if let fragment, !fragment.isEmpty { + var fragmentComponents = fragment.components(separatedBy: "?") + + guard let firstFragmentComponent = fragmentComponents.first else { + self.fragment = fragmentQuery + return + } + + fragmentComponents = [firstFragmentComponent, fragmentQuery] + + self.fragment = fragmentComponents.joined(separator: "?") + + } else { + fragment = "?" + fragmentQuery + } + } + } +} diff --git a/ElementX/Sources/Screens/Other/GenericCallLinkCoordinator.swift b/ElementX/Sources/Screens/Other/GenericCallLinkCoordinator.swift index 5174ad0dd..28c0e8a9b 100644 --- a/ElementX/Sources/Screens/Other/GenericCallLinkCoordinator.swift +++ b/ElementX/Sources/Screens/Other/GenericCallLinkCoordinator.swift @@ -54,21 +54,25 @@ private struct WebView: UIViewRepresentable { } func updateUIView(_ webView: WKWebView, context: Context) { - webView.load(URLRequest(url: url)) + webView.load(URLRequest(url: context.coordinator.url)) } @MainActor class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate { - private let url: URL + let url: URL private(set) var webView: WKWebView! init(url: URL) { if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) { - urlComponents.queryItems?.removeAll { $0.name == GenericCallLinkQueryParameters.appPrompt } - urlComponents.queryItems?.removeAll { $0.name == GenericCallLinkQueryParameters.confineToRoom } + var fragmentQueryItems = urlComponents.fragmentQueryItems ?? [] - urlComponents.queryItems?.append(.init(name: GenericCallLinkQueryParameters.appPrompt, value: "false")) - urlComponents.queryItems?.append(.init(name: GenericCallLinkQueryParameters.confineToRoom, value: "true")) + fragmentQueryItems.removeAll { $0.name == GenericCallLinkQueryParameters.appPrompt } + fragmentQueryItems.removeAll { $0.name == GenericCallLinkQueryParameters.confineToRoom } + + fragmentQueryItems.append(.init(name: GenericCallLinkQueryParameters.appPrompt, value: "false")) + fragmentQueryItems.append(.init(name: GenericCallLinkQueryParameters.confineToRoom, value: "true")) + + urlComponents.fragmentQueryItems = fragmentQueryItems if let adjustedURL = urlComponents.url { self.url = adjustedURL diff --git a/UnitTests/Sources/URLComponentsTests.swift b/UnitTests/Sources/URLComponentsTests.swift new file mode 100644 index 000000000..7403da1c0 --- /dev/null +++ b/UnitTests/Sources/URLComponentsTests.swift @@ -0,0 +1,111 @@ +// +// Copyright 2023 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 ElementX + +class URLComponentsTests: XCTestCase { + func testAddFragmentQueryItems() { + guard let url = URL(string: "https://test.matrix.org"), + var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + XCTFail("URL invalid") + return + } + + XCTAssertNil(components.fragmentQueryItems) + + let fragmentQueryItems: [URLQueryItem] = [.init(name: "first", value: "1"), .init(name: "second", value: "2")] + components.fragmentQueryItems = fragmentQueryItems + + XCTAssertEqual(components.url?.absoluteString, "https://test.matrix.org#?first=1&second=2") + } + + func testRemoveFragmentQueryItem() { + guard let url = URL(string: "https://test.matrix.org#random/data?first=1&second=2"), + var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + XCTFail("URL invalid") + return + } + + XCTAssertNotNil(components.fragmentQueryItems) + guard var fragmentQueryItems = components.fragmentQueryItems else { + return + } + + fragmentQueryItems.removeAll { $0.name == "first" } + + components.fragmentQueryItems = fragmentQueryItems + + XCTAssertEqual(components.url?.absoluteString, "https://test.matrix.org#random/data?second=2") + } + + func testAppendFragmentQueryItem() { + guard let url = URL(string: "https://test.matrix.org#/random/data?first=1&second=2"), + var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + XCTFail("URL invalid") + return + } + + XCTAssertNotNil(components.fragmentQueryItems) + guard var fragmentQueryItems = components.fragmentQueryItems else { + return + } + + fragmentQueryItems.insert(.init(name: "mr in between", value: "hello"), at: 1) + + components.fragmentQueryItems = fragmentQueryItems + + XCTAssertEqual(components.url?.absoluteString, "https://test.matrix.org#/random/data?first=1&mr%20in%20between=hello&second=2") + } + + func testChangeFragmentQueryItemValue() { + guard let url = URL(string: "https://test.matrix.org#/random/data?first=1&second=2"), + var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + XCTFail("URL invalid") + return + } + + XCTAssertNotNil(components.fragmentQueryItems) + guard var fragmentQueryItems = components.fragmentQueryItems else { + return + } + + fragmentQueryItems[0].value = "last" + + components.fragmentQueryItems = fragmentQueryItems + + XCTAssertEqual(components.url?.absoluteString, "https://test.matrix.org#/random/data?first=last&second=2") + } + + func testElementCallParameters() { + guard let url = URL(string: "https://call.element.io/room#/callName?appPrompt=true&confineToRoom=false"), + var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + XCTFail("URL invalid") + return + } + + components.fragmentQueryItems?.removeAll { $0.name == "appPrompt" } + components.fragmentQueryItems?.removeAll { $0.name == "confineToRoom" } + + components.fragmentQueryItems?.append(.init(name: "skipLobby", value: "true")) + + components.fragmentQueryItems?.append(.init(name: "appPrompt", value: "false")) + components.fragmentQueryItems?.append(.init(name: "confineToRoom", value: "true")) + + XCTAssertEqual(components.url?.absoluteString, "https://call.element.io/room#/callName?skipLobby=true&appPrompt=false&confineToRoom=true") + } +}