Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WCM] Detect installed wallets #991

Merged
merged 3 commits into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Example/DApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>metamask</string>
<string>trust</string>
<string>safe</string>
<string>zerion</string>
<string>rainbow</string>
<string>spot</string>
Comment on lines +7 to +12
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ignaciosantise how did you solve this?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dapps will have to modify their info.plist/android manifest manually to support the feature: https://docs.walletconnect.com/2.0/advanced/walletconnectmodal/about#for-ios

</array>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just food for thought:
A long time ago I tried to solve the problem with the manual editing of supported wallet lists and stopped at some point. I'm not sure whether it makes sense at all, but maybe you will find it useful and polish it into a solid solution.

It is possible to modify LSApplicationQuerySchemes on compile using some script i.e.

BASE_PLIST="${SRCROOT}/WalletApp/Other/Info.plist"

/usr/libexec/PlistBuddy -c 'Add :LSApplicationQueriesSchemes array' "$BASE_PLIST"
/usr/libexec/PlistBuddy -c 'Add :LSApplicationQueriesSchemes: string metamask' "$BASE_PLIST"
/usr/libexec/PlistBuddy -c 'Add :LSApplicationQueriesSchemes: string trust' "$BASE_PLIST"
/usr/libexec/PlistBuddy -c 'Add :LSApplicationQueriesSchemes: string showcase' "$BASE_PLIST"

If you want to support this list dynamically (explorer api what not) it is also possible to request them while building, smth like:

WALLETS_SCHEME=${TEMP_DIR}/wallets-scheme.json
BASE_PLIST="${SRCROOT}/WalletApp/Other/Info.plist"

curl -o $WALLETS_SCHEME https://walletconnect.com/wallets-scheme.json

jq -c '.[]' $WALLETS_SCHEME | while read scheme; do
    /usr/libexec/PlistBuddy -c 'Add :LSApplicationQueriesSchemes: string $scheme' "$BASE_PLIST"
done

For developers, it will be enough to add the script above to Build Phases

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably a great way how to deal with it forward, but for now, we are sticking to simple solution and mentioning it in docs that in order to detect installed wallets they need to populate these LSApplicationQueriesSchemes

<key>CFBundleVersion</key>
<string>7</string>
<key>CFBundleShortVersionString</key>
Expand Down
138 changes: 82 additions & 56 deletions Sources/WalletConnectModal/Modal/ModalViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ final class ModalViewModel: ObservableObject {
var isShown: Binding<Bool>
let interactor: ModalSheetInteractor
let uiApplicationWrapper: UIApplicationWrapper
let recentWalletStorage: RecentWalletsStorage

@Published private(set) var destinationStack: [Destination] = [.welcome]
@Published private(set) var uri: String?
Expand All @@ -52,11 +53,9 @@ final class ModalViewModel: ObservableObject {
}

var filteredWallets: [Listing] {
if searchTerm.isEmpty { return sortByRecent(wallets) }

return sortByRecent(
wallets.filter { $0.name.lowercased().contains(searchTerm.lowercased()) }
)
wallets
.sortByRecent()
.filter(searchTerm: searchTerm)
}

private var disposeBag = Set<AnyCancellable>()
Expand All @@ -65,11 +64,13 @@ final class ModalViewModel: ObservableObject {
init(
isShown: Binding<Bool>,
interactor: ModalSheetInteractor,
uiApplicationWrapper: UIApplicationWrapper = .live
uiApplicationWrapper: UIApplicationWrapper = .live,
recentWalletStorage: RecentWalletsStorage = RecentWalletsStorage()
) {
self.isShown = isShown
self.interactor = interactor
self.uiApplicationWrapper = uiApplicationWrapper
self.recentWalletStorage = recentWalletStorage

interactor.sessionSettlePublisher
.receive(on: DispatchQueue.main)
Expand All @@ -82,7 +83,7 @@ final class ModalViewModel: ObservableObject {

interactor.sessionRejectionPublisher
.receive(on: DispatchQueue.main)
.sink { (proposal, reason) in
.sink { _, reason in

print(reason)
self.toast = Toast(style: .error, message: reason.message)
Expand Down Expand Up @@ -124,15 +125,17 @@ final class ModalViewModel: ObservableObject {

func onBackButton() {
guard destinationStack.count != 1 else { return }
_ = destinationStack.popLast()

withAnimation {
_ = destinationStack.popLast()
}

if destinationStack.last?.hasSearch == false {
searchTerm = ""
}
}

func onCopyButton() {

guard let uri else {
toast = Toast(style: .error, message: "No uri found")
return
Expand Down Expand Up @@ -164,79 +167,103 @@ final class ModalViewModel: ObservableObject {
// Small deliberate delay to ensure animations execute properly
try await Task.sleep(nanoseconds: 500_000_000)

withAnimation {
self.wallets = wallets.sorted {
guard let lhs = $0.order else {
return false
}

guard let rhs = $1.order else {
return true
}

return lhs < rhs
}

loadRecentWallets()
}
loadRecentWallets()
checkWhetherInstalled(wallets: wallets)

self.wallets = wallets
.sortByOrder()
.sortByInstalled()
} catch {
toast = Toast(style: .error, message: error.localizedDescription)
}
}
}

// MARK: - Recent Wallets
// MARK: - Sorting and filtering

private extension ModalViewModel {

func sortByRecent(_ input: [Listing]) -> [Listing] {
input.sorted { lhs, rhs in
guard let lhsLastTimeUsed = lhs.lastTimeUsed else {
private extension Array where Element: Listing {
func sortByOrder() -> [Listing] {
sorted {
guard let lhs = $0.order else {
return false
}

guard let rhsLastTimeUsed = rhs.lastTimeUsed else {
guard let rhs = $1.order else {
return true
}

return lhsLastTimeUsed > rhsLastTimeUsed
return lhs < rhs
}
}

func loadRecentWallets() {
RecentWalletsStorage().recentWallets.forEach { wallet in

guard let lastTimeUsed = wallet.lastTimeUsed else {
return
func sortByInstalled() -> [Listing] {
sorted { lhs, rhs in
if lhs.installed, !rhs.installed {
return true
}

// Consider Recent only for 3 days
if abs(lastTimeUsed.timeIntervalSinceNow) > (24 * 60 * 60 * 3) {
return
if !lhs.installed, rhs.installed {
return false
}

setLastTimeUsed(wallet.id, date: lastTimeUsed)
return false
}
}

func saveRecentWallets() {
RecentWalletsStorage().recentWallets = Array(wallets.filter {
$0.lastTimeUsed != nil
}.prefix(5))
func sortByRecent() -> [Listing] {
sorted { lhs, rhs in
guard let lhsLastTimeUsed = lhs.lastTimeUsed else {
return false
}

guard let rhsLastTimeUsed = rhs.lastTimeUsed else {
return true
}

return lhsLastTimeUsed > rhsLastTimeUsed
}
}

func setLastTimeUsed(_ walletId: String, date: Date = Date()) {
guard let index = wallets.firstIndex(where: {
$0.id == walletId
}) else {
func filter(searchTerm: String) -> [Listing] {
if searchTerm.isEmpty { return self }

return filter {
$0.name.lowercased().contains(searchTerm.lowercased())
}
}
}

// MARK: - Recent & Installed Wallets

private extension ModalViewModel {
func checkWhetherInstalled(wallets: [Listing]) {
guard let schemes = Bundle.main.object(forInfoDictionaryKey: "LSApplicationQueriesSchemes") as? [String] else {
return
}

var copy = wallets[index]
copy.lastTimeUsed = date
wallets[index] = copy

saveRecentWallets()
wallets.forEach {
if
let walletScheme = $0.mobile.native,
!walletScheme.isEmpty,
schemes.contains(walletScheme.replacingOccurrences(of: "://", with: ""))
{
$0.installed = uiApplicationWrapper.canOpenURL(URL(string: walletScheme)!)
}
}
}

func loadRecentWallets() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that ModalViewModel is going to be a bit overloaded with this "Recent Wallets" logic and it looks a bit like a good place for potential bugs because of sorting/comparing/adding/moving/removing. I do not claim that there are bugs, but I would use more stable solution for such a task as LRU Cache algorithm.

You can create a separate object, which will be responsible for storing and handling recent objects limited by capacity, and add some tests to make sure this always works as expected.

But it's not mandatory, just another food for thought 😁

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved some of the logic to the RecentWalletsStorage object and tidied up the sorting logic to be more declarative.

recentWalletStorage.recentWallets.forEach { wallet in
guard let lastTimeUsed = wallet.lastTimeUsed else { return }
setLastTimeUsed(wallet.id, date: lastTimeUsed)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already have wallet instance here, why we need to search wallet by id in setLastTimeUsed again?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wallet instance here is the one loaded from UserDefaults, while the one I'm matching in setLastTimeUsed is part of my wallets state property.

}
}

func setLastTimeUsed(_ id: String, date: Date = Date()) {
wallets.first {
$0.id == id
}?.lastTimeUsed = date
recentWalletStorage.recentWallets = wallets
}
}

Expand All @@ -248,7 +275,6 @@ protocol WalletDeeplinkHandler {
}

extension ModalViewModel: WalletDeeplinkHandler {

func openAppstore(wallet: Listing) {
guard
let storeLinkString = wallet.app.ios,
Expand Down Expand Up @@ -277,7 +303,7 @@ extension ModalViewModel: WalletDeeplinkHandler {
if !success {
self.toast = Toast(style: .error, message: DeeplinkErrors.failedToOpen.localizedDescription)
}
}
}
} else {
throw DeeplinkErrors.noWalletLinkFound
}
Expand Down
47 changes: 34 additions & 13 deletions Sources/WalletConnectModal/Modal/RecentWalletStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,44 @@ final class RecentWalletsStorage {

var recentWallets: [Listing] {
get {
guard
let data = defaults.data(forKey: "recentWallets"),
let wallets = try? JSONDecoder().decode([Listing].self, from: data)
else {
return []
}

return wallets
loadRecentWallets()
}
set {
guard
let walletsData = try? JSONEncoder().encode(newValue)
else {
return
saveRecentWallets(newValue)
}
}

func loadRecentWallets() -> [Listing] {
guard
let data = defaults.data(forKey: "recentWallets"),
let wallets = try? JSONDecoder().decode([Listing].self, from: data)
else {
return []
}

return wallets.filter { listing in
guard let lastTimeUsed = listing.lastTimeUsed else {
assertionFailure("Shouldn't happen we stored wallet without `lastTimeUsed`")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain why we are storing without lastTimeUsed please?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should never happen that we store it without lastTimeUsed, so I've added this just as an extra check If we ever do.

return false
}

defaults.set(walletsData, forKey: "recentWallets")
// Consider Recent only for 3 days
return abs(lastTimeUsed.timeIntervalSinceNow) > (24 * 60 * 60 * 3)
}
}

func saveRecentWallets(_ listings: [Listing]) {

let subset = Array(listings.filter {
$0.lastTimeUsed != nil
}.prefix(5))

guard
let walletsData = try? JSONEncoder().encode(subset)
else {
return
}

defaults.set(walletsData, forKey: "recentWallets")
}
}
5 changes: 3 additions & 2 deletions Sources/WalletConnectModal/Modal/Screens/WalletList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct WalletList: View {
case .viewAll:
viewAll()
.frame(minHeight: 250)
.animation(nil)
default:
EmptyView()
}
Expand Down Expand Up @@ -173,8 +174,8 @@ struct WalletList: View {
.foregroundColor(.foreground1)
.multilineTextAlignment(.center)

Text("RECENT")
.opacity(wallet.lastTimeUsed != nil ? 1 : 0)
Text(wallet.lastTimeUsed != nil ? "RECENT" : "INSTALLED")
.opacity(wallet.lastTimeUsed != nil || wallet.installed ? 1 : 0)
.font(.system(size: 10))
.foregroundColor(.foreground3)
.padding(.horizontal, 12)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,40 @@ struct ListingsResponse: Codable {
let listings: [String: Listing]
}

struct Listing: Codable, Hashable, Identifiable {
class Listing: Codable, Hashable, Identifiable {
init(
id: String,
name: String,
homepage: String,
order: Int? = nil,
imageId: String,
app: Listing.App,
mobile: Listing.Links,
desktop: Listing.Links,
lastTimeUsed: Date? = nil,
installed: Bool = false
) {
self.id = id
self.name = name
self.homepage = homepage
self.order = order
self.imageId = imageId
self.app = app
self.mobile = mobile
self.desktop = desktop
self.lastTimeUsed = lastTimeUsed
self.installed = installed
}

func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(name)
}

static func == (lhs: Listing, rhs: Listing) -> Bool {
lhs.id == rhs.id && lhs.name == rhs.name
}

let id: String
let name: String
let homepage: String
Expand All @@ -13,7 +46,9 @@ struct Listing: Codable, Hashable, Identifiable {
let app: App
let mobile: Links
let desktop: Links

var lastTimeUsed: Date?
var installed: Bool = false

private enum CodingKeys: String, CodingKey {
case id
Expand Down
Loading