Skip to content

Commit

Permalink
Deeplink to wallets
Browse files Browse the repository at this point in the history
  • Loading branch information
radeknovis committed May 25, 2023
1 parent 1862b95 commit db3b57f
Show file tree
Hide file tree
Showing 10 changed files with 294 additions and 55 deletions.
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
17 changes: 17 additions & 0 deletions Sources/Web3Modal/Helpers/UIApplicationWrapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import UIKit

struct UIApplicationWrapper {
let openURL: (URL) async -> Void
let canOpenURL: (URL) -> Bool
}

extension UIApplicationWrapper {
static let live = Self(
openURL: { @MainActor url in
await UIApplication.shared.open(url)
},
canOpenURL: { url in
UIApplication.shared.canOpenURL(url)
}
)
}
2 changes: 1 addition & 1 deletion Sources/Web3Modal/Modal/ModalContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
85 changes: 45 additions & 40 deletions Sources/Web3Modal/Modal/ModalInteractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Session, Never> { get }
}

final class DefaultModalSheetInteractor: ModalSheetInteractor {
let projectId: String
let metadata: AppMetadata
let socketFactory: WebSocketFactory

lazy var sessionSettlePublisher: AnyPublisher<Session, Never> = 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<String> = ["eth_sendTransaction", "personal_sign", "eth_signTypedData"]
let blockchains: Set<Blockchain> = [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<String> = ["eth_sendTransaction", "personal_sign", "eth_signTypedData"]
let blockchains: Set<Blockchain> = [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
}
}
2 changes: 1 addition & 1 deletion Sources/Web3Modal/Modal/ModalSheet+Previews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 6 additions & 1 deletion Sources/Web3Modal/Modal/ModalSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
}

Expand Down
115 changes: 104 additions & 11 deletions Sources/Web3Modal/Modal/ModalViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,32 @@ extension ModalSheet {
}

final class ModalViewModel: ObservableObject {
private var disposeBag = Set<AnyCancellable>()
private let interactor: Interactor
private let projectId: String

@Published var isShown: Binding<Bool>
@Published private(set) var isShown: Binding<Bool>
private let projectId: String
private let interactor: ModalSheetInteractor
private let uiApplicationWrapper: UIApplicationWrapper

private var disposeBag = Set<AnyCancellable>()
private var deeplinkUri: String?

@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<Bool>, projectId: String, interactor: Interactor) {
init(
isShown: Binding<Bool>,
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)
Expand Down Expand Up @@ -66,7 +75,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
Expand All @@ -85,6 +96,17 @@ extension ModalSheet {
UIPasteboard.general.string = uri
}

func onWalletTapped(index: Int) {
guard let wallet = wallets[safe: index] else { return }

Task {
await navigateToDeepLink(
universalLink: wallet.mobile.universal ?? "",
nativeLink: wallet.mobile.native ?? ""
)
}
}

func imageUrl(for listing: Listing?) -> URL? {
guard let listing = listing else { return nil }

Expand All @@ -94,3 +116,74 @@ extension ModalSheet {
}
}
}

private extension ModalSheet.ModalViewModel {
enum Errors: Error {
case noWalletLinkFound
}

@MainActor
func navigateToDeepLink(universalLink: String, nativeLink: String) async {
do {
let nativeUrlString = formatNativeUrlString(nativeLink)
let universalUrlString = formatUniversalUrlString(universalLink)

if let nativeUrl = nativeUrlString?.toURL() {
await uiApplicationWrapper.openURL(nativeUrl)
} else if let universalUrl = universalUrlString?.toURL() {
await 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)
}
}
10 changes: 10 additions & 0 deletions Tests/Web3ModalTests/Helpers/FuncTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
struct FuncTest<T> {
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() {}
}
34 changes: 34 additions & 0 deletions Tests/Web3ModalTests/Mocks/ModalSheetInteractorMock.swift
Original file line number Diff line number Diff line change
@@ -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<Session, Never> {
Result.Publisher(Session(topic: "", pairingTopic: "", peer: .stub(), namespaces: [:], expiryDate: Date()))
.eraseToAnyPublisher()
}
}
Loading

0 comments on commit db3b57f

Please sign in to comment.