diff --git a/WireNetwork/Sources/WireNetwork/APIs/Rest/ConnectionsAPI/ConnectionsAPI.swift b/WireNetwork/Sources/WireNetwork/APIs/Rest/ConnectionsAPI/ConnectionsAPI.swift index e076670a73e..4b3672e26d8 100644 --- a/WireNetwork/Sources/WireNetwork/APIs/Rest/ConnectionsAPI/ConnectionsAPI.swift +++ b/WireNetwork/Sources/WireNetwork/APIs/Rest/ConnectionsAPI/ConnectionsAPI.swift @@ -25,4 +25,22 @@ public protocol ConnectionsAPI { /// Fetch all connections . func getConnections() async throws -> PayloadPager<[Connection]> + + /// Send connection request to user + /// - Parameters: + /// - domain: domain info + /// - userID: userID + + #if DEBUG + func sendConnectionRequest(domain: String, userId: String) async throws + #endif + + /// Accept connection request from user + /// - Parameters: + /// - domain: domain info + /// - userID: userID + + #if DEBUG + func acceptConnectionRequest(domain: String, userId: String) async throws + #endif } diff --git a/WireNetwork/Sources/WireNetwork/APIs/Rest/ConnectionsAPI/ConnectionsAPIV0.swift b/WireNetwork/Sources/WireNetwork/APIs/Rest/ConnectionsAPI/ConnectionsAPIV0.swift index f9122504a8c..8c0c74dc97c 100644 --- a/WireNetwork/Sources/WireNetwork/APIs/Rest/ConnectionsAPI/ConnectionsAPIV0.swift +++ b/WireNetwork/Sources/WireNetwork/APIs/Rest/ConnectionsAPI/ConnectionsAPIV0.swift @@ -61,6 +61,39 @@ class ConnectionsAPIV0: ConnectionsAPI, VersionedAPI { .parse(code: response.statusCode, data: data) } } + + func sendConnectionRequest( + domain: String, + userId: String + ) async throws { + + let request = try URLRequestBuilder(path: "\(pathPrefix)/connections/\(domain)/\(userId)") + .withMethod(.post) + .build() + + let (_, response) = try await apiService.executeRequest(request, requiringAccessToken: true) + guard response.statusCode == HTTPStatusCode.created.rawValue else { + throw ConnectionsAPIError.invalidBody + } + } + + func acceptConnectionRequest( + domain: String, + userId: String + ) async throws { + + let body = try JSONEncoder.defaultEncoder.encode(RequestBodyAcceptConnectionRequestV0.accepted) + + let request = try URLRequestBuilder(path: "\(pathPrefix)/connections/\(domain)/\(userId)") + .withMethod(.put) + .withBody(body, contentType: .json) + .build() + + let (_, response) = try await apiService.executeRequest(request, requiringAccessToken: true) + guard response.statusCode == HTTPStatusCode.ok.rawValue else { + throw ConnectionsAPIError.invalidBody + } + } } private struct PaginatedConnectionListV0: Decodable, ToAPIModelConvertible { @@ -120,3 +153,8 @@ private struct ConnectionResponseV0: Decodable, ToAPIModelConvertible { ) } } + +private struct RequestBodyAcceptConnectionRequestV0: Encodable { + static let accepted = Self() + let status: String = "accepted" +} diff --git a/WireUI/Sources/WireLocators/Locators.swift b/WireUI/Sources/WireLocators/Locators.swift index 2faf79e6af8..8ad6afe8bd7 100644 --- a/WireUI/Sources/WireLocators/Locators.swift +++ b/WireUI/Sources/WireLocators/Locators.swift @@ -99,6 +99,7 @@ public enum Locators { case conversationTitleButton case conversationDetailsButton case message + case imageCell = "ImageCell" } public enum BackupOrRestorePage: String { @@ -266,4 +267,13 @@ public enum Locators { case closeButton } + public enum ShareExtensionPage: String { + + case imageTile = "PXGGridLayout-Info" + case shareButton = "PUOneUpBarButtonItemIdentifierShare" + case chooseConversations = "chevron" + case sendButtonOnShareExtension + case continueButton = "Continue" + } + } diff --git a/wire-ios/Wire-iOS Share Extension/Sources/View Controllers/ShareExtensionViewController.swift b/wire-ios/Wire-iOS Share Extension/Sources/View Controllers/ShareExtensionViewController.swift index 4cd3cfda4de..d586ee6a018 100644 --- a/wire-ios/Wire-iOS Share Extension/Sources/View Controllers/ShareExtensionViewController.swift +++ b/wire-ios/Wire-iOS Share Extension/Sources/View Controllers/ShareExtensionViewController.swift @@ -28,6 +28,7 @@ import WireDesign import WireDomain import WireFoundation import WireLinkPreview +import WireLocators import WireLogging import WireNetwork import WireShareEngine @@ -185,6 +186,8 @@ final class ShareExtensionViewController: SLComposeServiceViewController { guard let item = navigationController?.navigationBar.items?.first else { return } item.rightBarButtonItem?.action = #selector(appendPostTapped) item.rightBarButtonItem?.title = L10n.ShareExtension.SendButton.title + item.rightBarButtonItem?.accessibilityIdentifier = Locators.ShareExtensionPage.sendButtonOnShareExtension + .rawValue item .titleView = UIImageView( image: WireStyleKit.imageOfLogo(color: UIColor.Wire.primaryLabel) diff --git a/wire-ios/WireUITests/Helper/UserHelper.swift b/wire-ios/WireUITests/Helper/UserHelper.swift index beac8ef35a5..511014d0e35 100644 --- a/wire-ios/WireUITests/Helper/UserHelper.swift +++ b/wire-ios/WireUITests/Helper/UserHelper.swift @@ -34,6 +34,7 @@ class UserHelper { let teamsAPI: TeamsAPI let selfUserAPI: SelfUserAPI let conversationsAPI: ConversationsAPI + let connectionsAPI: ConnectionsAPI private let cookieStorage = MockCookieStorage() private let authenticationManager = MockAuthManager() @@ -54,6 +55,7 @@ class UserHelper { self.teamsAPI = TeamsAPIBuilder(apiService: networkStack.apiService) .makeAPI(for: apiVersion) self.conversationsAPI = ConversationsAPIBuilder(apiService: networkStack.apiService).makeAPI(for: apiVersion) + self.connectionsAPI = ConnectionsAPIBuilder(apiService: networkStack.apiService).makeAPI(for: apiVersion) } func basicAuth(_ backend: BackendTarget = BackendContext.current) -> String { @@ -99,11 +101,16 @@ class UserHelper { verificationCode: nil, label: nil ) + authenticationManager.accessToken = accessToken // Set username try await selfUserAPI.updateHandle(handle: user.username) + // Store id in UserInfo + let selfUser = try await selfUserAPI.getSelfUser() + user.id = selfUser.id.uuidString + createdUsers.append(user) return user } @@ -290,6 +297,31 @@ class UserHelper { _ = try await conversationsAPI.createGroupConversation(parameters: params) } + + func sendConnectionRequestToUser( + domain: String, + userId: String + ) async throws { + + _ = try await connectionsAPI.sendConnectionRequest(domain: domain, userId: userId) + } + + func acceptConnectionRequestFromUser( + domain: String, + user1: UserInfo, + userId: String + ) async throws { + + let (_, accessToken) = try await authenticationAPI.login( + email: user1.email, + password: user1.password, + verificationCode: nil, + label: nil + ) + authenticationManager.accessToken = accessToken + + try await connectionsAPI.acceptConnectionRequest(domain: domain, userId: userId) + } } extension BackendEnvironment { diff --git a/wire-ios/WireUITests/Pages/ActiveConversationPage .swift b/wire-ios/WireUITests/Pages/ActiveConversationPage .swift index 765b0cfcdbd..2ec99ab3328 100644 --- a/wire-ios/WireUITests/Pages/ActiveConversationPage .swift +++ b/wire-ios/WireUITests/Pages/ActiveConversationPage .swift @@ -61,6 +61,10 @@ class ActiveConversationPage: PageModel { app.buttons[Locators.ActiveConversationPage.conversationDetailsButton.rawValue] } + var imageCell: XCUIElement { + app.otherElements[Locators.ActiveConversationPage.imageCell.rawValue] + } + func fetchMessages() -> [String] { var messages: [String] = [] for i in 0 ..< messageLabels.count { diff --git a/wire-ios/WireUITests/Pages/PhotosAppPage.swift b/wire-ios/WireUITests/Pages/PhotosAppPage.swift new file mode 100644 index 00000000000..aee38a9e117 --- /dev/null +++ b/wire-ios/WireUITests/Pages/PhotosAppPage.swift @@ -0,0 +1,110 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireLocators +import XCTest + +class PhotosAppPage: PageModel { + private let photosApp: XCUIApplication + private let timeout: TimeInterval = 2 + + override var pageMainElement: XCUIElement { + photosApp.windows.firstMatch + } + + init(photosApp: XCUIApplication) throws { + self.photosApp = photosApp + try super.init() + } + + var continueButtonOnWhatsNewPhotosApp: XCUIElement { + photosApp.buttons[Locators.ShareExtensionPage.continueButton.rawValue].firstMatch + } + + var firstImageTile: XCUIElement { + photosApp.images[Locators.ShareExtensionPage.imageTile.rawValue].firstMatch + } + + var shareButton: XCUIElement { + photosApp.buttons + .matching(identifier: Locators.ShareExtensionPage.shareButton.rawValue) + .firstMatch + } + + var shareToWireApp: XCUIElement { + photosApp.cells["Wire"].firstMatch + } + + var chooseConversationButton: XCUIElement { + photosApp.buttons[Locators.ShareExtensionPage.chooseConversations.rawValue].firstMatch + } + + var sendButton: XCUIElement { + photosApp.buttons[Locators.ShareExtensionPage.sendButtonOnShareExtension.rawValue].firstMatch + } + + func selectConversation(name: String) -> XCUIElement { + photosApp.staticTexts[name].firstMatch + } + + @discardableResult + func continueWhatsNewIfPresent() throws -> PhotosAppPage { + if continueButtonOnWhatsNewPhotosApp.waitForExistence(timeout: timeout) { + continueButtonOnWhatsNewPhotosApp.tap() + } + return self + } + + @discardableResult + func openFirstImage() throws -> PhotosAppPage { + try continueWhatsNewIfPresent() + XCTAssertTrue(firstImageTile.waitForExistence(timeout: 10)) + // NOTE: Tap the center via coordinates because Photos grid cells are often not directly hittable in UITests + firstImageTile + .coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + .tap() + return self + } + + @discardableResult + func shareImageToWire() throws -> PhotosAppPage { + XCTAssertTrue(shareButton.waitForExistence(timeout: timeout)) + shareButton.tap() + XCTAssertTrue(shareToWireApp.waitForExistence(timeout: timeout)) + shareToWireApp.tap() + return self + } + + func chooseConversationAndSend(name: String) throws { + defer { photosApp.terminate() } + + XCTAssertTrue(chooseConversationButton.waitForExistence(timeout: timeout)) + chooseConversationButton.tap() + + let conversationToSend = selectConversation(name: name) + XCTAssertTrue(conversationToSend.waitForExistence(timeout: timeout)) + conversationToSend.tap() + + XCTAssertTrue(sendButton.waitForExistence(timeout: timeout)) + sendButton.tap() + + XCTAssertTrue(shareButton.waitForExistence(timeout: timeout)) + photosApp.terminate() + } + +} diff --git a/wire-ios/WireUITests/ShareExtensionTests.swift b/wire-ios/WireUITests/ShareExtensionTests.swift new file mode 100644 index 00000000000..cf07968e5f5 --- /dev/null +++ b/wire-ios/WireUITests/ShareExtensionTests.swift @@ -0,0 +1,74 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import WireFoundation +import WireLocators +import XCTest + +final class ShareExtensionTests: WireUITestCase { + + private let photosAppBundleId = XCUIApplication(bundleIdentifier: "com.apple.mobileslideshow") + private let timeout: TimeInterval = 2 + + @MainActor + private func launchPhotosApp() async throws { + photosAppBundleId.launch() + XCTAssertTrue(photosAppBundleId.wait(for: .runningForeground, timeout: timeout)) + } + + @MainActor + private func shareFirstPhotoToWire(name: String) async throws { + let photosApp = try PhotosAppPage(photosApp: photosAppBundleId) + try photosApp + .openFirstImage() + .shareImageToWire() + .chooseConversationAndSend(name: name) + } + + @MainActor + private func switchBackToWireApp() async throws { + app.activate() + if !app.wait(for: .runningForeground, timeout: timeout) { + app.launch() + _ = app.wait(for: .runningForeground, timeout: timeout) + } + } + + @MainActor + func test_ShareImageOnetoOne() async throws { + + let user1 = try await userHelper.createPersonalUser() + let user2 = try await userHelper.createPersonalUser() + let domain = BackendTarget.staging.domainInfo + + try await userHelper.sendConnectionRequestToUser(domain: domain, userId: user1.id) + try await userHelper.acceptConnectionRequestFromUser(domain: domain, user1: user1, userId: user2.id) + let firstTimePage = try app.loginUser(email: user1.email, password: user1.password) + let conversationsPage = try firstTimePage.acceptPopup(with: self) + + try await launchPhotosApp() + try await shareFirstPhotoToWire(name: user2.name) + try await switchBackToWireApp() + + let activeConversationPage = try conversationsPage.openConversation() + + XCTAssertTrue( + activeConversationPage.imageCell.exists, "No Image cell found" + ) + } +}