mirror of
https://github.com/BlueWallet/BlueWallet.git
synced 2025-02-26 00:14:49 +01:00
361 lines
15 KiB
Swift
361 lines
15 KiB
Swift
// Data/WatchDataSource.swift
|
|
|
|
import Foundation
|
|
import WatchConnectivity
|
|
import Security
|
|
import Combine
|
|
import ClockKit
|
|
|
|
struct NotificationName {
|
|
static let dataUpdated = Notification.Name(rawValue: "Notification.WalletDataSource.Updated")
|
|
}
|
|
struct Notifications {
|
|
static let dataUpdated = Notification(name: NotificationName.dataUpdated)
|
|
}
|
|
|
|
/// Represents the group user defaults keys.
|
|
/// Ensure these match the keys used in your iOS app for sharing data.
|
|
|
|
/// Handles WatchConnectivity and data synchronization between iOS and Watch apps.
|
|
class WatchDataSource: NSObject, ObservableObject, WCSessionDelegate {
|
|
// MARK: - Singleton Instance
|
|
|
|
static func postDataUpdatedNotification() {
|
|
NotificationCenter.default.post(Notifications.dataUpdated)
|
|
}
|
|
|
|
|
|
static let shared = WatchDataSource()
|
|
|
|
// MARK: - Published Properties
|
|
|
|
/// The list of wallets to be displayed on the Watch app.
|
|
@Published var wallets: [Wallet] = []
|
|
|
|
@Published var isDataLoaded: Bool = false
|
|
|
|
// MARK: - Private Properties
|
|
|
|
private let groupUserDefaults = UserDefaults(suiteName: UserDefaultsGroupKey.GroupName.rawValue)
|
|
private let keychain = KeychainHelper.shared
|
|
private let session: WCSession
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
// MARK: - Initializer
|
|
|
|
private override init() {
|
|
guard WCSession.isSupported() else {
|
|
print("WCSession is not supported on this device.")
|
|
// Initialize with a default session but mark as unsupported
|
|
self.session = WCSession.default
|
|
super.init()
|
|
return
|
|
}
|
|
self.session = WCSession.default
|
|
super.init()
|
|
self.session.delegate = self
|
|
loadKeychainData()
|
|
setupBindings()
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Starts the WatchConnectivity session.
|
|
func startSession() {
|
|
// Check if keychain has existing wallets data before activating session
|
|
if let existingData = keychain.retrieve(service: UserDefaultsGroupKey.WatchAppBundleIdentifier.rawValue, account: UserDefaultsGroupKey.BundleIdentifier.rawValue),
|
|
!existingData.isEmpty {
|
|
session.activate()
|
|
} else {
|
|
print("Keychain is empty. Skipping WCSession activation.")
|
|
}
|
|
}
|
|
|
|
/// Deactivates the WatchConnectivity session (if needed).
|
|
/// Note: WCSession does not provide a deactivate method, but you can handle any necessary cleanup here.
|
|
func deactivateSession() {
|
|
// Handle any necessary cleanup here.
|
|
}
|
|
|
|
// MARK: - Data Binding
|
|
|
|
/// Sets up bindings to observe changes to `wallets` and perform actions accordingly.
|
|
private func setupBindings() {
|
|
// Observe changes to wallets and perform actions if needed.
|
|
$wallets
|
|
.sink { [weak self] updatedWallets in
|
|
self?.saveWalletsToKeychain()
|
|
self?.reloadComplications()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// MARK: - Keychain Operations
|
|
|
|
/// Loads wallets data from the Keychain asynchronously.
|
|
private func loadKeychainData() {
|
|
DispatchQueue.global(qos: .background).async { [weak self] in
|
|
guard let self = self else { return }
|
|
guard let existingData = self.keychain.retrieve(service: UserDefaultsGroupKey.WatchAppBundleIdentifier.rawValue, account: UserDefaultsGroupKey.BundleIdentifier.rawValue),
|
|
let decodedWallets = try? JSONDecoder().decode([Wallet].self, from: existingData) else {
|
|
print("No existing wallets data found in Keychain.")
|
|
return
|
|
}
|
|
|
|
// Filter wallets to include only on-chain wallets.
|
|
let onChainWallets = decodedWallets.filter { $0.chain == .onchain }
|
|
|
|
DispatchQueue.main.async {
|
|
if onChainWallets != self.wallets {
|
|
self.wallets = onChainWallets
|
|
print("Loaded \(onChainWallets.count) on-chain wallets from Keychain.")
|
|
}
|
|
self.isDataLoaded = true
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Saves the current wallets data to the Keychain asynchronously.
|
|
private func saveWalletsToKeychain() {
|
|
DispatchQueue.global(qos: .background).async { [weak self] in
|
|
guard let self = self else { return }
|
|
guard self.session.isReachable || self.session.activationState == .activated else {
|
|
print("iPhone is not reachable or session is not active. Skipping save to Keychain.")
|
|
return
|
|
}
|
|
guard let encodedData = try? JSONEncoder().encode(self.wallets) else {
|
|
print("Failed to encode wallets.")
|
|
return
|
|
}
|
|
let success = self.keychain.save(encodedData, service: UserDefaultsGroupKey.WatchAppBundleIdentifier.rawValue, account: UserDefaultsGroupKey.BundleIdentifier.rawValue)
|
|
if success {
|
|
print("Successfully saved wallets to Keychain.")
|
|
} else {
|
|
print("Failed to save wallets to Keychain.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - WatchConnectivity Methods
|
|
|
|
/// Handles the activation completion of the WCSession.
|
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
|
if let error = error {
|
|
print("WCSession activation failed with error: \(error.localizedDescription)")
|
|
} else {
|
|
print("WCSession activated with state: \(activationState.rawValue)")
|
|
// Request current wallets data from iOS app.
|
|
}
|
|
}
|
|
|
|
/// Handles received messages from the iOS app.
|
|
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
|
|
processReceivedData(message)
|
|
}
|
|
|
|
/// Handles received application context updates from the iOS app.
|
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
|
|
if applicationContext.isEmpty { return }
|
|
processReceivedData(applicationContext)
|
|
}
|
|
|
|
// MARK: - Data Processing
|
|
|
|
/// Processes received data from the iOS app.
|
|
/// - Parameter data: The data received either as a message or application context.
|
|
private func processReceivedData(_ data: [String: Any]) {
|
|
if let preferredFiatCurrency = data["preferredFiatCurrency"] as? String {
|
|
// Handle preferred fiat currency update.
|
|
groupUserDefaults?.set(preferredFiatCurrency, forKey: "preferredCurrency")
|
|
|
|
// Fetch and update market data based on the new preferred currency.
|
|
updateMarketData(for: preferredFiatCurrency)
|
|
} else {
|
|
// Assume the data contains wallets information.
|
|
processWalletsData(walletsInfo: data)
|
|
}
|
|
}
|
|
|
|
/// Processes wallets data received from the iOS app.
|
|
/// - Parameter walletsInfo: The wallets data received as a dictionary.
|
|
private func processWalletsData(walletsInfo: [String: Any]) {
|
|
guard let walletsToProcess = walletsInfo["wallets"] as? [[String: Any]] else {
|
|
print("No wallets data found in received context.")
|
|
return
|
|
}
|
|
|
|
var processedWallets: [Wallet] = []
|
|
|
|
for entry in walletsToProcess {
|
|
guard let label = entry["label"] as? String,
|
|
let balance = entry["balance"] as? Double,
|
|
let typeString = entry["type"] as? String,
|
|
let preferredBalanceUnitString = entry["preferredBalanceUnit"] as? String,
|
|
let chainString = entry["chain"] as? String,
|
|
let transactions = entry["transactions"] as? [[String: Any]] else {
|
|
print("Incomplete wallet entry found. Skipping.")
|
|
continue
|
|
}
|
|
|
|
var transactionsProcessed: [Transaction] = []
|
|
for transactionEntry in transactions {
|
|
guard let time = transactionEntry["time"] as? String,
|
|
let memo = transactionEntry["memo"] as? String,
|
|
let amount = transactionEntry["amount"] as? Double,
|
|
let type = transactionEntry["type"] as? String else {
|
|
print("Incomplete transaction entry found. Skipping.")
|
|
continue
|
|
}
|
|
|
|
let transactionType = TransactionType(rawString: type)
|
|
let transaction = Transaction(time: time, memo: memo, type: transactionType, amount: "\(amount) BTC")
|
|
transactionsProcessed.append(transaction)
|
|
}
|
|
|
|
let receiveAddress = entry["receiveAddress"] as? String ?? ""
|
|
let xpub = entry["xpub"] as? String ?? ""
|
|
let hideBalance = entry["hideBalance"] as? Bool ?? false
|
|
let paymentCode = entry["paymentCode"] as? String
|
|
let chain = Chain(rawString: chainString)
|
|
|
|
let wallet = Wallet(
|
|
label: label,
|
|
balance: "\(balance) BTC",
|
|
type: WalletType(rawString: typeString),
|
|
chain: chain,
|
|
preferredBalanceUnit: BitcoinUnit(rawString: preferredBalanceUnitString),
|
|
receiveAddress: receiveAddress,
|
|
transactions: transactionsProcessed,
|
|
xpub: xpub,
|
|
hideBalance: hideBalance,
|
|
paymentCode: paymentCode
|
|
)
|
|
processedWallets.append(wallet)
|
|
}
|
|
|
|
// Update the published `wallets` property on the main thread.
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.wallets = processedWallets
|
|
print("Updated wallets from received context.")
|
|
WatchDataSource.postDataUpdatedNotification()
|
|
}
|
|
}
|
|
|
|
/// Fetches market data based on the preferred fiat currency.
|
|
/// - Parameter fiatCurrency: The preferred fiat currency string.
|
|
private func updateMarketData(for fiatCurrency: String) {
|
|
guard !fiatCurrency.isEmpty else {
|
|
print("Invalid fiat currency provided")
|
|
return
|
|
}
|
|
|
|
MarketAPI.fetchPrice(currency: fiatCurrency) { [weak self] (marketData, error) in
|
|
guard let self = self else { return }
|
|
if let error = error {
|
|
print("Failed to fetch market data: \(error.localizedDescription)")
|
|
// Consider implementing retry logic or fallback mechanism
|
|
return
|
|
}
|
|
|
|
guard let marketData = marketData as? MarketData else {
|
|
print("Invalid market data format received")
|
|
return
|
|
}
|
|
|
|
do {
|
|
let widgetData = WidgetDataStore(rate: "\(marketData.rate)", lastUpdate: marketData.dateString, rateDouble: marketData.rate)
|
|
if let encodedData = try? JSONEncoder().encode(widgetData) {
|
|
self.groupUserDefaults?.set(encodedData, forKey: MarketData.string)
|
|
print("Market data updated for currency: \(fiatCurrency)")
|
|
} else {
|
|
throw NSError(domain: "WatchDataSource", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode market data"])
|
|
}
|
|
} catch {
|
|
print("Failed to process market data: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Wallet Actions
|
|
|
|
/// Requests a Lightning Invoice from the iOS app.
|
|
/// - Parameters:
|
|
/// - walletIdentifier: The index of the wallet in the `wallets` array.
|
|
/// - amount: The amount for the invoice.
|
|
/// - description: An optional description for the invoice.
|
|
/// - responseHandler: A closure to handle the invoice string received from the iOS app.
|
|
func requestLightningInvoice(walletIdentifier: Int, amount: Double, description: String?, responseHandler: @escaping (_ invoice: String) -> Void) {
|
|
let timeoutSeconds = 30.0
|
|
let timeoutTimer = Timer.scheduledTimer(withTimeInterval: timeoutSeconds, repeats: false) { _ in
|
|
print("Lightning invoice request timed out")
|
|
responseHandler("")
|
|
}
|
|
|
|
guard wallets.indices.contains(walletIdentifier) else {
|
|
timeoutTimer.invalidate()
|
|
responseHandler("")
|
|
return
|
|
}
|
|
let message: [String: Any] = [
|
|
"request": "createInvoice",
|
|
"walletIndex": walletIdentifier,
|
|
"amount": amount,
|
|
"description": description ?? ""
|
|
]
|
|
session.sendMessage(message, replyHandler: { reply in
|
|
timeoutTimer.invalidate()
|
|
if let invoicePaymentRequest = reply["invoicePaymentRequest"] as? String, !invoicePaymentRequest.isEmpty {
|
|
responseHandler(invoicePaymentRequest)
|
|
} else {
|
|
responseHandler("")
|
|
}
|
|
}, errorHandler: { error in
|
|
timeoutTimer.invalidate()
|
|
print("Error requesting Lightning Invoice: \(error.localizedDescription)")
|
|
responseHandler("")
|
|
})
|
|
}
|
|
|
|
/// Toggles the visibility of the wallet's balance.
|
|
/// - Parameters:
|
|
/// - walletIdentifier: The index of the wallet in the `wallets` array.
|
|
/// - hideBalance: A boolean indicating whether to hide the balance.
|
|
/// - responseHandler: A closure to handle the success status.
|
|
func toggleWalletHideBalance(walletIdentifier: UUID, hideBalance: Bool, responseHandler: @escaping (_ success: Bool) -> Void) {
|
|
guard wallets.indices.contains(walletIdentifier.hashValue) else {
|
|
responseHandler(false)
|
|
return
|
|
}
|
|
let message: [String: Any] = [
|
|
"message": "hideBalance",
|
|
"walletIndex": walletIdentifier,
|
|
"hideBalance": hideBalance
|
|
]
|
|
session.sendMessage(message, replyHandler: { reply in
|
|
responseHandler(true)
|
|
}, errorHandler: { error in
|
|
print("Error toggling hide balance: \(error.localizedDescription)")
|
|
responseHandler(false)
|
|
})
|
|
}
|
|
|
|
// MARK: - Complications Reload
|
|
|
|
/// Reloads all active complications on the Watch face.
|
|
private func reloadComplications() {
|
|
let server = CLKComplicationServer.sharedInstance()
|
|
server.activeComplications?.forEach { complication in
|
|
server.reloadTimeline(for: complication)
|
|
print("[Complication] Reloaded timeline for \(complication.family.rawValue)")
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension WatchDataSource {
|
|
static var mock: WatchDataSource {
|
|
let mockDataSource = WatchDataSource()
|
|
mockDataSource.wallets = [Wallet.mock]
|
|
return mockDataSource
|
|
}
|
|
}
|