Controller.swift (17710B)
1 /* 2 * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. 3 * See LICENSE.md 4 */ 5 /** 6 * Controller 7 * 8 * @author Marc Stibane 9 */ 10 import Foundation 11 import AVFoundation 12 import SwiftUI 13 import SymLog 14 import os.log 15 import CoreHaptics 16 import Network 17 import taler_swift 18 19 enum BackendState { 20 case none 21 case instantiated 22 case initing 23 case ready 24 case error 25 } 26 27 enum UrlCommand: String, Codable { 28 case unknown 29 case withdraw 30 case withdrawExchange 31 case addExchange 32 case pay 33 case payPull 34 case payPush 35 case payTemplate 36 case refund 37 #if GNU_TALER 38 case devExperiment 39 #endif 40 41 var isOutgoing: Bool { 42 switch self { 43 case .pay, .payPull, .payTemplate: 44 true 45 default: 46 false 47 } 48 } 49 50 var localizedCommand: String { 51 switch self { 52 case .unknown: String(EMPTYSTRING) 53 case .withdraw, 54 .withdrawExchange: String(localized: "Withdraw", 55 comment: "UrlCommand") 56 case .addExchange: String(localized: "Add Exchange", 57 comment: "UrlCommand") 58 case .pay: String(localized: "Pay merchant", 59 comment: "UrlCommand") 60 case .payPull: String(localized: "Pay others", 61 comment: "UrlCommand") 62 case .payPush: String(localized: "Receive", 63 comment: "UrlCommand") 64 case .payTemplate: String(localized: "Pay ...", 65 comment: "UrlCommand") 66 case .refund: String(localized: "Refund", 67 comment: "UrlCommand") 68 #if GNU_TALER 69 case .devExperiment: String("DevExperiment") 70 #endif 71 } 72 } 73 var transactionType: TransactionType { 74 switch self { 75 case .unknown: .dummy 76 case .withdraw: .withdrawal 77 case .withdrawExchange: .withdrawal 78 case .addExchange: .withdrawal 79 case .pay: .payment 80 case .payPull: .scanPullDebit 81 case .payPush: .scanPushCredit 82 case .payTemplate: .payment 83 case .refund: .refund 84 #if GNU_TALER 85 case .devExperiment: .dummy 86 #endif 87 } 88 } 89 } 90 91 struct ScannedURL: Identifiable { 92 var id: String { 93 return url.absoluteString 94 } 95 var url: URL 96 var command: UrlCommand 97 var amount: Amount? 98 var baseURL: String? 99 var scope: ScopeInfo? 100 var time: Date 101 } 102 103 // MARK: - 104 class Controller: ObservableObject { 105 public static let shared = Controller() 106 private let symLog = SymLogC() 107 108 @Published var balances: [Balance] = [] 109 @Published var scannedURLs: [ScannedURL] = [] 110 111 @Published var backendState: BackendState = .none // only used for launch animation 112 @Published var currencyTicker: Int = 0 // updates whenever a new currency is added 113 114 @Published var isConnected: Bool = true 115 @Published var oimModeActive: Bool = false 116 @Published var oimSheetActive: Bool = false 117 @Published var diagnosticModeEnabled: Bool = false 118 @AppStorage("useHaptics") var useHaptics: Bool = true // extension mustn't define this, so it must be here 119 @AppStorage("playSoundsI") var playSoundsI: Int = 1 // extension mustn't define this, so it must be here 120 @AppStorage("playSoundsB") var playSoundsB: Bool = false 121 @AppStorage("talerFontIndex") var talerFontIndex: Int = 0 // extension mustn't define this, so it must be here 122 let hapticCapability = CHHapticEngine.capabilitiesForHardware() 123 let logger = Logger(subsystem: "net.taler.gnu", category: "Controller") 124 let player = AVQueuePlayer() 125 let semaphore = AsyncSemaphore(value: 1) 126 private var currencyInfos: [ScopeInfo : CurrencyInfo] 127 var exchanges: [Exchange] 128 var messageForSheet: String? = nil 129 130 private let monitor = NWPathMonitor() 131 132 private var diagnosticModeObservation: NSKeyValueObservation? 133 #if OIM 134 func setOIMmode(for newOrientation: UIDeviceOrientation, _ sheetActive: Bool) { 135 let isLandscapeRight = newOrientation == .landscapeRight 136 oimSheetActive = sheetActive && isLandscapeRight 137 oimModeActive = sheetActive ? false 138 : isLandscapeRight 139 // print("😱 oimSheetActive = \(oimSheetActive)") 140 } 141 #endif 142 143 @discardableResult 144 func saveURL(_ passedURL: URL, urlCommand: UrlCommand) -> Bool { 145 let savedURL = scannedURLs.first { scannedURL in 146 scannedURL.url == passedURL 147 } 148 if savedURL == nil { // doesn't exist yet 149 var save = false 150 switch urlCommand { 151 case .addExchange: save = true 152 case .withdraw: save = true 153 case .withdrawExchange: save = true 154 case .pay: save = true 155 case .payPull: save = true 156 case .payPush: save = true 157 case .payTemplate: save = true 158 159 default: break 160 } 161 if save { 162 let scannedURL = ScannedURL(url: passedURL, command: urlCommand, time: .now) 163 if scannedURLs.count > 5 { 164 self.logger.trace("removing: \(self.scannedURLs.first?.command.rawValue ?? "")") 165 scannedURLs.remove(at: 0) 166 } 167 scannedURLs.append(scannedURL) 168 self.logger.trace("saveURL: \(urlCommand.rawValue)") 169 return true 170 } 171 } 172 return false 173 } 174 175 func removeURL(_ passedURL: URL) { 176 scannedURLs.removeAll { scannedURL in 177 scannedURL.url == passedURL 178 } 179 } 180 func removeURLs(after: TimeInterval) { 181 let now = Date.now 182 scannedURLs.removeAll { scannedURL in 183 let timeInterval = now.timeIntervalSince(scannedURL.time) 184 self.logger.trace("timeInterval: \(timeInterval)") 185 return timeInterval > after 186 } 187 } 188 func updateAmount(_ amount: Amount, forSaved url: URL) { 189 if let index = scannedURLs.firstIndex(where: { $0.url == url }) { 190 var savedURL = scannedURLs[index] 191 savedURL.amount = amount 192 scannedURLs[index] = savedURL 193 } 194 } 195 func updateBase(_ baseURL: String, forSaved url: URL) { 196 if let index = scannedURLs.firstIndex(where: { $0.url == url }) { 197 var savedURL = scannedURLs[index] 198 savedURL.baseURL = baseURL 199 scannedURLs[index] = savedURL 200 } 201 } 202 203 204 func startObserving() { 205 let defaults = UserDefaults.standard 206 self.diagnosticModeObservation = defaults.observe(\.diagnosticModeEnabled, options: [.new, .old,.prior,.initial]) { [weak self](_, _) in 207 self?.diagnosticModeEnabled = UserDefaults.standard.diagnosticModeEnabled 208 } 209 } 210 211 func checkInternetConnection() { 212 monitor.pathUpdateHandler = { path in 213 let status = switch path.status { 214 case .satisfied: "active" 215 case .unsatisfied: "inactive" 216 default: "unknown" 217 } 218 self.logger.log("Internet connection is \(status)") 219 #if DEBUG // TODO: checkInternetConnection hintNetworkAvailabilityT 220 DispatchQueue.main.async { 221 if path.status == .unsatisfied { 222 self.isConnected = false 223 Task.detached { 224 await WalletModel.shared.hintNetworkAvailabilityT(false) 225 } 226 } else { 227 self.stopCheckingConnection() 228 self.isConnected = true 229 Task.detached { 230 await WalletModel.shared.hintNetworkAvailabilityT(true) 231 } 232 } 233 } 234 #endif 235 } 236 self.logger.log("Start monitoring internet connection") 237 let queue = DispatchQueue(label: "InternetMonitor") 238 monitor.start(queue: queue) 239 } 240 func stopCheckingConnection() { 241 self.logger.log("Stop monitoring internet connection") 242 monitor.cancel() 243 } 244 245 func printFonts() { 246 for family in UIFont.familyNames { 247 print(family) 248 for names in UIFont.fontNames(forFamilyName: family) { 249 print("== \(names)") 250 } 251 } 252 } 253 254 init() { 255 backendState = .instantiated 256 currencyTicker = 0 257 currencyInfos = [:] 258 exchanges = [] 259 balances = [] 260 // printFonts() 261 // checkInternetConnection() 262 startObserving() 263 } 264 // MARK: - 265 @MainActor 266 @discardableResult 267 func loadBalances(_ stack: CallStack,_ model: WalletModel) async -> Int? { 268 if let reloaded = try? await model.getBalances(stack.push()) { 269 if reloaded != balances { 270 for balance in reloaded { 271 let scope = balance.scopeInfo 272 checkInfo(for: scope, model: model) 273 } 274 self.logger.log("••Got new balances, will redraw") 275 balances = reloaded // redraw 276 } else { 277 self.logger.log("••Same balances, no redraw") 278 } 279 return reloaded.count 280 } 281 return nil 282 } 283 284 func balance(for scope: ScopeInfo) -> Balance? { 285 for balance in balances { 286 if balance.scopeInfo == scope { 287 return balance 288 } 289 } 290 return nil 291 } 292 // MARK: - 293 func exchange(for baseUrl: String) -> Exchange? { 294 for exchange in exchanges { 295 if exchange.exchangeBaseUrl == baseUrl { 296 return exchange 297 } 298 } 299 return nil 300 } 301 302 func info(for scope: ScopeInfo) -> CurrencyInfo? { 303 // return CurrencyInfo.euro() // Fake EUR instead of the real Currency 304 // return CurrencyInfo.francs() // Fake CHF instead of the real Currency 305 return currencyInfos[scope] 306 } 307 func info(for scope: ScopeInfo, _ ticker: Int) -> CurrencyInfo { 308 if ticker != currencyTicker { 309 print(" ❗️Yikes - race condition while getting info for \(scope.currency)") 310 } 311 return info(for: scope) ?? CurrencyInfo.zero(scope.currency) 312 } 313 314 func info2(for currency: String) -> CurrencyInfo? { 315 // return CurrencyInfo.euro() // Fake EUR instead of the real Currency 316 // return CurrencyInfo.francs() // Fake CHF instead of the real Currency 317 for (scope, info) in currencyInfos { 318 if scope.currency == currency { 319 return info 320 } 321 } 322 // logger.log(" ❗️ no info for \(currency)") 323 return nil 324 } 325 func info2(for currency: String, _ ticker: Int) -> CurrencyInfo { 326 if ticker != currencyTicker { 327 print(" ❗️Yikes - race condition while getting info for \(currency)") 328 } 329 return info2(for: currency) ?? CurrencyInfo.zero(currency) 330 } 331 332 func hasInfo(for currency: String) -> Bool { 333 for (scope, info) in currencyInfos { 334 if scope.currency == currency { 335 return true 336 } 337 } 338 // logger.log(" ❗️ no info for \(currency)") 339 return false 340 } 341 342 @MainActor 343 func exchange(for baseUrl: String?, model: WalletModel) async -> Exchange? { 344 if let baseUrl { 345 if let exchange1 = exchange(for: baseUrl) { 346 return exchange1 347 } 348 if let exchange2 = try? await model.getExchangeByUrl(url: baseUrl) { 349 // logger.log(" ❗️ will add \(baseUrl)") 350 exchanges.append(exchange2) 351 return exchange2 352 } 353 } 354 return nil 355 } 356 357 @MainActor 358 func updateInfo(_ scope: ScopeInfo, model: WalletModel) async { 359 if let info = try? await model.getCurrencyInfo(scope: scope) { 360 await setInfo(info, for: scope) 361 // logger.log(" ❗️info set for \(scope.currency)") 362 } 363 } 364 365 func checkCurrencyInfo(for baseUrl: String, model: WalletModel) async -> Exchange? { 366 if let exchange = await exchange(for: baseUrl, model: model) { 367 let scope = exchange.scopeInfo 368 if currencyInfos[scope] == nil { 369 logger.log(" ❗️got no info for \(baseUrl.trimURL) \(scope.currency) -> will update") 370 await updateInfo(scope, model: model) 371 } 372 return exchange 373 } else { 374 // Yikes❗️ TODO: error? 375 } 376 return nil 377 } 378 379 /// called whenever a new currency pops up - will first load the Exchange and then currencyInfos 380 func checkInfo(for scope: ScopeInfo, model: WalletModel) { 381 if currencyInfos[scope] == nil { 382 Task { 383 let exchange = await exchange(for: scope.url, model: model) 384 if let scope2 = exchange?.scopeInfo { 385 let exchangeName = scope2.url ?? "UNKNOWN" 386 logger.log(" ❗️got no info for \(scope.currency) -> will update \(exchangeName.trimURL)") 387 await updateInfo(scope2, model: model) 388 } else { 389 logger.error(" ❗️got no info for \(scope.currency), and couldn't load the exchange info❗️") 390 } 391 } 392 } 393 } 394 395 @MainActor 396 func getInfo(from baseUrl: String, model: WalletModel) async throws -> CurrencyInfo? { 397 let exchange = try await model.getExchangeByUrl(url: baseUrl) 398 let scope = exchange.scopeInfo 399 if let info = info(for: scope) { 400 return info 401 } 402 let info = try await model.getCurrencyInfo(scope: scope) 403 await setInfo(info, for: scope) 404 return info 405 } 406 407 @MainActor 408 func setInfo(_ newInfo: CurrencyInfo, for scope: ScopeInfo) async { 409 await semaphore.wait() 410 defer { semaphore.signal() } 411 412 currencyInfos[scope] = newInfo 413 currencyTicker += 1 // triggers published view update 414 } 415 // MARK: - 416 @MainActor 417 func initWalletCore(_ model: WalletModel, setTesting: Bool) 418 async throws { 419 if backendState == .instantiated { 420 backendState = .initing 421 do { 422 let versionInfo = try await model.initWalletCore(setTesting: setTesting) 423 WalletCore.shared.versionInfo = versionInfo 424 backendState = .ready // dismiss the launch animation 425 } catch { // rethrows 426 self.logger.error("\(error.localizedDescription)") 427 backendState = .error // ❗️Yikes app cannot continue 428 throw error 429 } 430 } else { 431 self.logger.fault("Yikes❗️ wallet-core already initialized") 432 } 433 } 434 } 435 436 // MARK: - 437 extension Controller { 438 func openURL(_ url: URL, stack: CallStack) -> UrlCommand { 439 guard let scheme = url.scheme else {return UrlCommand.unknown} 440 #if DEBUG 441 symLog.log(url) 442 #else 443 let host = url.host ?? " <- no command" 444 self.logger.trace("openURL(\(scheme)\(host)") 445 #endif 446 var uncrypted = false 447 var urlCommand = UrlCommand.unknown 448 switch scheme.lowercased() { 449 case "taler+http": 450 uncrypted = true 451 fallthrough 452 case "taler", "ext+taler", "web+taler": 453 urlCommand = talerScheme(url, uncrypted) 454 // case "payto": 455 // messageForSheet = url.absoluteString 456 // return paytoScheme(url) 457 default: 458 self.logger.error("unknown scheme: <\(scheme)>") // should never happen 459 } 460 saveURL(url, urlCommand: urlCommand) 461 return urlCommand 462 } 463 } 464 // MARK: - 465 extension Controller { 466 // func paytoScheme(_ url:URL) -> UrlCommand { 467 // let logItem = "scheme payto:// is not yet implemented" 468 // // TODO: write logItem to somewhere in Debug section of SettingsView 469 // symLog.log(logItem) // TODO: symLog.error(logItem) 470 // return UrlCommand.unknown 471 // } 472 473 func talerScheme(_ url:URL,_ uncrypted: Bool = false) -> UrlCommand { 474 if let command = url.host { 475 if uncrypted { 476 self.logger.trace("uncrypted http: taler://\(command)") 477 // TODO: uncrypted taler+http 478 } 479 switch command.lowercased() { 480 case "withdraw": return .withdraw 481 case "withdraw-exchange": return .withdrawExchange 482 case "add-exchange": return .addExchange 483 case "pay": return .pay 484 case "pay-pull": return .payPull 485 case "pay-push": return .payPush 486 case "pay-template": return .payTemplate 487 case "refund": return .refund 488 #if GNU_TALER 489 case "dev-experiment": return .devExperiment 490 #endif 491 default: 492 self.logger.error("❗️unknown command taler://\(command)") 493 } 494 messageForSheet = command.lowercased() 495 } else { 496 self.logger.error("❗️No taler command") 497 } 498 return .unknown 499 } 500 }