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 2 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
131 changes: 81 additions & 50 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,11 @@ final class ModalViewModel: ObservableObject {
}

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

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

private var disposeBag = Set<AnyCancellable>()
Expand All @@ -65,11 +66,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 Down Expand Up @@ -124,7 +127,10 @@ final class ModalViewModel: ObservableObject {

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

withAnimation {
_ = destinationStack.popLast()
}

if destinationStack.last?.hasSearch == false {
searchTerm = ""
Expand Down Expand Up @@ -164,79 +170,104 @@ 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()
}
self.wallets = wallets

checkInstalledWallets()
loadRecentWallets()
} catch {
toast = Toast(style: .error, message: error.localizedDescription)
}
}
}

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

private extension ModalViewModel {
private extension Array where Element: Listing {

func sortByRecent(_ input: [Listing]) -> [Listing] {
input.sorted { lhs, rhs in
guard let lhsLastTimeUsed = lhs.lastTimeUsed else {
func sortByOrder() -> [Listing] {
self.sorted {
Copy link
Contributor

Choose a reason for hiding this comment

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

as I understand every sort iterations: sortByOrder, sortByInstalled ... will trigger UI update es wallets is @published. Is here any was to do it in one UI update?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually, since it is in a single computed property like this, which does not sort in place but returns a new ordered sequence, it should trigger a single UI update once it orders everything.

var filteredWallets: [Listing] {
        wallets
            .sortByOrder()
            .sortByInstalled()
            .sortByRecent()
            .filter(searchTerm: searchTerm)
}

One concern might be that we are doing unnecessary iterations since order and installed won't change, so I will extract those to run only once.

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
func sortByInstalled() -> [Listing] {
self.sorted { lhs, rhs in
if lhs.installed, !rhs.installed {
return true
}

guard let lastTimeUsed = wallet.lastTimeUsed else {
return
if !lhs.installed, rhs.installed {
return false
}

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

func sortByRecent() -> [Listing] {
self.sorted { lhs, rhs in
guard let lhsLastTimeUsed = lhs.lastTimeUsed else {
return false
}

setLastTimeUsed(wallet.id, date: lastTimeUsed)
guard let rhsLastTimeUsed = rhs.lastTimeUsed else {
return true
}

return lhsLastTimeUsed > rhsLastTimeUsed
}
}

func saveRecentWallets() {
RecentWalletsStorage().recentWallets = Array(wallets.filter {
$0.lastTimeUsed != nil
}.prefix(5))
func filter(searchTerm: String) -> [Listing] {
if searchTerm.isEmpty { return self }

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

// MARK: - Recent & Installed Wallets

private extension ModalViewModel {

func setLastTimeUsed(_ walletId: String, date: Date = Date()) {
guard let index = wallets.firstIndex(where: {
$0.id == walletId
}) else {
func checkInstalledWallets() {

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 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