From 2d32f546a3d865c21daf11cf14d380ab5ee7e42f Mon Sep 17 00:00:00 2001 From: Radek Novak Date: Thu, 25 May 2023 14:13:51 +0200 Subject: [PATCH] Deeplink to wallets --- Package.swift | 5 +- .../Helpers/UIApplicationWrapper.swift | 19 +++ .../Web3Modal/Modal/ModalContainerView.swift | 2 +- Sources/Web3Modal/Modal/ModalInteractor.swift | 85 +++++++------- .../Web3Modal/Modal/ModalSheet+Previews.swift | 2 +- Sources/Web3Modal/Modal/ModalSheet.swift | 7 +- Sources/Web3Modal/Modal/ModalViewModel.swift | 111 ++++++++++++++++-- Tests/Web3ModalTests/Helpers/FuncTests.swift | 10 ++ .../Mocks/ModalSheetInteractorMock.swift | 34 ++++++ .../Web3ModalTests/ModalViewModelTests.swift | 80 +++++++++++++ 10 files changed, 300 insertions(+), 55 deletions(-) create mode 100644 Sources/Web3Modal/Helpers/UIApplicationWrapper.swift create mode 100644 Tests/Web3ModalTests/Helpers/FuncTests.swift create mode 100644 Tests/Web3ModalTests/Mocks/ModalSheetInteractorMock.swift create mode 100644 Tests/Web3ModalTests/ModalViewModelTests.swift diff --git a/Package.swift b/Package.swift index 6960b3734..e1db6a6fd 100644 --- a/Package.swift +++ b/Package.swift @@ -160,7 +160,10 @@ let package = Package( dependencies: ["JSONRPC", "TestingUtils"]), .testTarget( name: "CommonsTests", - dependencies: ["Commons", "TestingUtils"]) + dependencies: ["Commons", "TestingUtils"]), + .testTarget( + name: "Web3ModalTests", + dependencies: ["Web3Modal", "TestingUtils"]) ], swiftLanguageVersions: [.v5] ) diff --git a/Sources/Web3Modal/Helpers/UIApplicationWrapper.swift b/Sources/Web3Modal/Helpers/UIApplicationWrapper.swift new file mode 100644 index 000000000..f27b12de0 --- /dev/null +++ b/Sources/Web3Modal/Helpers/UIApplicationWrapper.swift @@ -0,0 +1,19 @@ +import UIKit + +struct UIApplicationWrapper { + let openURL: (URL) -> Void + let canOpenURL: (URL) -> Bool +} + +extension UIApplicationWrapper { + static let live = Self( + openURL: { url in + Task { @MainActor in + await UIApplication.shared.open(url) + } + }, + canOpenURL: { url in + UIApplication.shared.canOpenURL(url) + } + ) +} diff --git a/Sources/Web3Modal/Modal/ModalContainerView.swift b/Sources/Web3Modal/Modal/ModalContainerView.swift index 312120dbf..5722cdd3d 100644 --- a/Sources/Web3Modal/Modal/ModalContainerView.swift +++ b/Sources/Web3Modal/Modal/ModalContainerView.swift @@ -34,7 +34,7 @@ public struct ModalContainerView: View { viewModel: .init( isShown: $showModal, projectId: projectId, - interactor: .init(projectId: projectId, metadata: metadata, webSocketFactory: webSocketFactory) + interactor: DefaultModalSheetInteractor(projectId: projectId, metadata: metadata, webSocketFactory: webSocketFactory) )) .transition(.move(edge: .bottom)) .animation(.spring(), value: showModal) diff --git a/Sources/Web3Modal/Modal/ModalInteractor.swift b/Sources/Web3Modal/Modal/ModalInteractor.swift index 69ac7ea41..4d2ba5af6 100644 --- a/Sources/Web3Modal/Modal/ModalInteractor.swift +++ b/Sources/Web3Modal/Modal/ModalInteractor.swift @@ -2,51 +2,56 @@ import WalletConnectPairing import WalletConnectSign import Combine -extension ModalSheet { - final class Interactor { - let projectId: String - let metadata: AppMetadata - let socketFactory: WebSocketFactory +protocol ModalSheetInteractor { + func getListings() async throws -> [Listing] + func connect() async throws -> WalletConnectURI + + var sessionSettlePublisher: AnyPublisher { get } +} + +final class DefaultModalSheetInteractor: ModalSheetInteractor { + let projectId: String + let metadata: AppMetadata + let socketFactory: WebSocketFactory + + lazy var sessionSettlePublisher: AnyPublisher = Sign.instance.sessionSettlePublisher + + init(projectId: String, metadata: AppMetadata, webSocketFactory: WebSocketFactory) { + self.projectId = projectId + self.metadata = metadata + self.socketFactory = webSocketFactory - lazy var sessionsPublisher: AnyPublisher<[Session], Never> = Sign.instance.sessionsPublisher + Pair.configure(metadata: metadata) + Networking.configure(projectId: projectId, socketFactory: socketFactory) + } + + func getListings() async throws -> [Listing] { + + let httpClient = HTTPNetworkClient(host: "explorer-api.walletconnect.com") + let response = try await httpClient.request( + ListingsResponse.self, + at: ExplorerAPI.getListings(projectId: projectId) + ) + + return response.listings.values.compactMap { $0 } + } + + func connect() async throws -> WalletConnectURI { - init(projectId: String, metadata: AppMetadata, webSocketFactory: WebSocketFactory) { - self.projectId = projectId - self.metadata = metadata - self.socketFactory = webSocketFactory - - Pair.configure(metadata: metadata) - Networking.configure(projectId: projectId, socketFactory: socketFactory) - } + let uri = try await Pair.instance.create() - func getListings() async throws -> [Listing] { - - let httpClient = HTTPNetworkClient(host: "explorer-api.walletconnect.com") - let response = try await httpClient.request( - ListingsResponse.self, - at: ExplorerAPI.getListings(projectId: projectId) + let methods: Set = ["eth_sendTransaction", "personal_sign", "eth_signTypedData"] + let blockchains: Set = [Blockchain("eip155:1")!] + let namespaces: [String: ProposalNamespace] = [ + "eip155": ProposalNamespace( + chains: blockchains, + methods: methods, + events: [] ) + ] - return response.listings.values.compactMap { $0 } - } + try await Sign.instance.connect(requiredNamespaces: namespaces, topic: uri.topic) - func connect() async throws -> WalletConnectURI { - - let uri = try await Pair.instance.create() - - let methods: Set = ["eth_sendTransaction", "personal_sign", "eth_signTypedData"] - let blockchains: Set = [Blockchain("eip155:1")!, Blockchain("eip155:137")!] - let namespaces: [String: ProposalNamespace] = [ - "eip155": ProposalNamespace( - chains: blockchains, - methods: methods, - events: [] - ) - ] - - try await Sign.instance.connect(requiredNamespaces: namespaces, topic: uri.topic) - - return uri - } + return uri } } diff --git a/Sources/Web3Modal/Modal/ModalSheet+Previews.swift b/Sources/Web3Modal/Modal/ModalSheet+Previews.swift index f4be2b253..161c15bb0 100644 --- a/Sources/Web3Modal/Modal/ModalSheet+Previews.swift +++ b/Sources/Web3Modal/Modal/ModalSheet+Previews.swift @@ -37,7 +37,7 @@ struct ModalSheet_Previews: PreviewProvider { viewModel: .init( isShown: .constant(true), projectId: projectId, - interactor: .init( + interactor: DefaultModalSheetInteractor( projectId: projectId, metadata: metadata, webSocketFactory: WebSocketFactoryMock() diff --git a/Sources/Web3Modal/Modal/ModalSheet.swift b/Sources/Web3Modal/Modal/ModalSheet.swift index 19928cb65..4d4f42bf8 100644 --- a/Sources/Web3Modal/Modal/ModalSheet.swift +++ b/Sources/Web3Modal/Modal/ModalSheet.swift @@ -120,7 +120,7 @@ public struct ModalSheet: View { private func gridItem(for index: Int) -> some View { let wallet: Listing? = viewModel.wallets[safe: index] - if #available(iOS 15.0, *) { + if #available(iOS 14.0, *) { VStack { AsyncImage(url: viewModel.imageUrl(for: wallet)) { image in image @@ -151,6 +151,11 @@ public struct ModalSheet: View { } .redacted(reason: wallet == nil ? .placeholder : []) .frame(maxWidth: 80, maxHeight: 96) + .onTapGesture { + Task { + await viewModel.onWalletTapped(index: index) + } + } } } diff --git a/Sources/Web3Modal/Modal/ModalViewModel.swift b/Sources/Web3Modal/Modal/ModalViewModel.swift index 39682c72b..921e9b260 100644 --- a/Sources/Web3Modal/Modal/ModalViewModel.swift +++ b/Sources/Web3Modal/Modal/ModalViewModel.swift @@ -22,23 +22,31 @@ extension ModalSheet { } final class ModalViewModel: ObservableObject { - private var disposeBag = Set() - private let interactor: Interactor + @Published private(set) var isShown: Binding private let projectId: String + private let interactor: ModalSheetInteractor + private let uiApplicationWrapper: UIApplicationWrapper + + private var disposeBag = Set() + private var deeplinkUri: String? - @Published var isShown: Binding - - @Published var uri: String? - @Published var destination: Destination = .wallets - @Published var errorMessage: String? - @Published var wallets: [Listing] = [] + @Published private(set) var uri: String? + @Published private(set) var destination: Destination = .wallets + @Published private(set) var errorMessage: String? + @Published private(set) var wallets: [Listing] = [] - init(isShown: Binding, projectId: String, interactor: Interactor) { + init( + isShown: Binding, + projectId: String, + interactor: ModalSheetInteractor, + uiApplicationWrapper: UIApplicationWrapper = .live + ) { self.isShown = isShown self.interactor = interactor self.projectId = projectId + self.uiApplicationWrapper = uiApplicationWrapper - interactor.sessionsPublisher + interactor.sessionSettlePublisher .receive(on: DispatchQueue.main) .sink { sessions in print(sessions) @@ -66,7 +74,9 @@ extension ModalSheet { @MainActor func createURI() async { do { - uri = try await interactor.connect().absoluteString + let wcUri = try await interactor.connect() + uri = wcUri.absoluteString + deeplinkUri = wcUri.deeplinkUri } catch { print(error) errorMessage = error.localizedDescription @@ -85,6 +95,15 @@ extension ModalSheet { UIPasteboard.general.string = uri } + func onWalletTapped(index: Int) { + guard let wallet = wallets[safe: index] else { return } + + navigateToDeepLink( + universalLink: wallet.mobile.universal ?? "", + nativeLink: wallet.mobile.native ?? "" + ) + } + func imageUrl(for listing: Listing?) -> URL? { guard let listing = listing else { return nil } @@ -94,3 +113,73 @@ extension ModalSheet { } } } + +private extension ModalSheet.ModalViewModel { + enum Errors: Error { + case noWalletLinkFound + } + + func navigateToDeepLink(universalLink: String, nativeLink: String) { + do { + let nativeUrlString = formatNativeUrlString(nativeLink) + let universalUrlString = formatUniversalUrlString(universalLink) + + if let nativeUrl = nativeUrlString?.toURL() { + uiApplicationWrapper.openURL(nativeUrl) + } else if let universalUrl = universalUrlString?.toURL() { + uiApplicationWrapper.openURL(universalUrl) + } else { + throw Errors.noWalletLinkFound + } + } catch { + let alertController = UIAlertController(title: "Unable to open the app", message: nil, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + UIApplication.shared.windows.first?.rootViewController?.present(alertController, animated: true, completion: nil) + } + } + + func isHttpUrl(url: String) -> Bool { + return url.hasPrefix("http://") || url.hasPrefix("https://") + } + + func formatNativeUrlString(_ string: String) -> String? { + if string.isEmpty { return nil } + + if isHttpUrl(url: string) { + return formatUniversalUrlString(string) + } + + var safeAppUrl = string + if !safeAppUrl.contains("://") { + safeAppUrl = safeAppUrl.replacingOccurrences(of: "/", with: "").replacingOccurrences(of: ":", with: "") + safeAppUrl = "\(safeAppUrl)://" + } + + guard let deeplinkUri else { return nil } + + return "\(safeAppUrl)wc?uri=\(deeplinkUri)" + } + + func formatUniversalUrlString(_ string: String) -> String? { + if string.isEmpty { return nil } + + if !isHttpUrl(url: string) { + return formatNativeUrlString(string) + } + + var plainAppUrl = string + if plainAppUrl.hasSuffix("/") { + plainAppUrl = String(plainAppUrl.dropLast()) + } + + guard let deeplinkUri else { return nil } + + return "\(plainAppUrl)/wc?uri=\(deeplinkUri)" + } +} + +private extension String { + func toURL() -> URL? { + URL(string: self) + } +} diff --git a/Tests/Web3ModalTests/Helpers/FuncTests.swift b/Tests/Web3ModalTests/Helpers/FuncTests.swift new file mode 100644 index 000000000..5e23eda44 --- /dev/null +++ b/Tests/Web3ModalTests/Helpers/FuncTests.swift @@ -0,0 +1,10 @@ +struct FuncTest { + private(set) var values: [T] = [] + var wasCalled: Bool { !values.isEmpty } + var wasNotCalled: Bool { !wasCalled } + var callsCount: Int { values.count } + var wasCalledOnce: Bool { values.count == 1 } + var currentValue: T? { values.last } + mutating func call(_ value: T) { values.append(value) } + init() {} +} diff --git a/Tests/Web3ModalTests/Mocks/ModalSheetInteractorMock.swift b/Tests/Web3ModalTests/Mocks/ModalSheetInteractorMock.swift new file mode 100644 index 000000000..c2f9e7e54 --- /dev/null +++ b/Tests/Web3ModalTests/Mocks/ModalSheetInteractorMock.swift @@ -0,0 +1,34 @@ +import Combine +import Foundation +import WalletConnectSign +import WalletConnectUtils +@testable import Web3Modal +@testable import WalletConnectSign + +final class ModalSheetInteractorMock: ModalSheetInteractor { + + static let listingsStub: [Listing] = [ + Listing(id: UUID().uuidString, name: "Sample App", homepage: "https://example.com", order: 1, imageId: UUID().uuidString, app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: "sampleapp://deeplink", universal: "https://example.com/universal")), + Listing(id: UUID().uuidString, name: "Awesome App", homepage: "https://example.com/awesome", order: 2, imageId: UUID().uuidString, app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: "awesomeapp://deeplink", universal: "https://example.com/awesome/universal")), + Listing(id: UUID().uuidString, name: "Cool App", homepage: "https://example.com/cool", order: 3, imageId: UUID().uuidString, app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: "coolapp://deeplink", universal: "https://example.com/cool/universal")) + ] + + var listings: [Listing] + + init(listings: [Listing] = ModalSheetInteractorMock.listingsStub) { + self.listings = listings + } + + func getListings() async throws -> [Web3Modal.Listing] { + listings + } + + func connect() async throws -> WalletConnectURI { + .init(topic: "foo", symKey: "bar", relay: .init(protocol: "irn", data: nil)) + } + + var sessionSettlePublisher: AnyPublisher { + Result.Publisher(Session(topic: "", pairingTopic: "", peer: .stub(), namespaces: [:], expiryDate: Date())) + .eraseToAnyPublisher() + } +} diff --git a/Tests/Web3ModalTests/ModalViewModelTests.swift b/Tests/Web3ModalTests/ModalViewModelTests.swift new file mode 100644 index 000000000..7965a201d --- /dev/null +++ b/Tests/Web3ModalTests/ModalViewModelTests.swift @@ -0,0 +1,80 @@ +import TestingUtils +@testable import Web3Modal +import XCTest + +final class ModalViewModelTests: XCTestCase { + private var sut: ModalSheet.ModalViewModel! + + private var openURLFuncTest: FuncTest! + private var canOpenURLFuncTest: FuncTest! + private var expectation: XCTestExpectation! + + override func setUpWithError() throws { + try super.setUpWithError() + + openURLFuncTest = .init() + canOpenURLFuncTest = .init() + + sut = .init( + isShown: .constant(true), + projectId: "", + interactor: ModalSheetInteractorMock(listings: [ + Listing(id: "1", name: "Sample App", homepage: "https://example.com", order: 1, imageId: "1", app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: nil, universal: "https://example.com/universal")), + Listing(id: "2", name: "Awesome App", homepage: "https://example.com/awesome", order: 2, imageId: "2", app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: "awesomeapp://deeplink", universal: "https://awesome.com/awesome/universal")), + ]), + uiApplicationWrapper: .init( + openURL: { url in + self.openURLFuncTest.call(url) + self.expectation.fulfill() + }, + canOpenURL: { url in + self.canOpenURLFuncTest.call(url) + self.expectation.fulfill() + return true + } + ) + ) + } + + override func tearDownWithError() throws { + sut = nil + openURLFuncTest = nil + canOpenURLFuncTest = nil + try super.tearDownWithError() + } + + func test_onWalletTapped() async throws { + await sut.fetchWallets() + await sut.createURI() + + XCTAssertEqual(sut.uri, "wc:foo@2?symKey=bar&relay-protocol=irn") + + XCTAssertEqual(sut.wallets.count, 2) + XCTAssertEqual(sut.wallets, [ + Listing(id: "1", name: "Sample App", homepage: "https://example.com", order: 1, imageId: "1", app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: nil, universal: "https://example.com/universal")), + Listing(id: "2", name: "Awesome App", homepage: "https://example.com/awesome", order: 2, imageId: "2", app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: "awesomeapp://deeplink", universal: "https://awesome.com/awesome/universal")), + ]) + + expectation = XCTestExpectation(description: "Wait for openUrl to be called") + + sut.onWalletTapped(index: 0) + + XCTWaiter.wait(for: [expectation], timeout: 3) + + XCTAssertEqual( + openURLFuncTest.currentValue, + URL(string: "https://example.com/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn")! + ) + + expectation = XCTestExpectation(description: "Wait for openUrl to be called 2nd time") + + sut.onWalletTapped(index: 1) + + XCTWaiter.wait(for: [expectation], timeout: 3) + + XCTAssertEqual( + openURLFuncTest.currentValue, + URL(string: "awesomeapp://deeplinkwc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn")! + ) + } +}