Controller.swift (18384B)
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 payment service", 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 @Published var talerURI: URL? = nil 119 @AppStorage("useHaptics") var useHaptics: Bool = true // extension mustn't define this, so it must be here 120 @AppStorage("playSoundsI") var playSoundsI: Int = 1 // extension mustn't define this, so it must be here 121 @AppStorage("playSoundsB") var playSoundsB: Bool = false 122 @AppStorage("talerFontIndex") var talerFontIndex: Int = 0 // extension mustn't define this, so it must be here 123 #if DEBUG 124 @AppStorage("developerMode") var developerMode: Bool = true 125 #else 126 @AppStorage("developerMode") var developerMode: Bool = false 127 #endif 128 let hapticCapability = CHHapticEngine.capabilitiesForHardware() 129 let logger = Logger(subsystem: "net.taler.gnu", category: "Controller") 130 let player = AVQueuePlayer() 131 let semaphore = AsyncSemaphore(value: 1) 132 private var currencyInfos: [ScopeInfo : CurrencyInfo] 133 var exchanges: [Exchange] 134 var messageForSheet: String? = nil 135 136 private let monitor = NWPathMonitor() 137 138 private var diagnosticModeObservation: NSKeyValueObservation? 139 #if OIM 140 func setOIMmode(for newOrientation: UIDeviceOrientation, _ sheetActive: Bool) { 141 let isLandscapeRight = newOrientation == .landscapeRight 142 oimSheetActive = sheetActive && isLandscapeRight 143 oimModeActive = sheetActive ? false 144 : isLandscapeRight 145 // print("😱 oimSheetActive = \(oimSheetActive)") 146 } 147 #endif 148 149 @discardableResult 150 func saveURL(_ passedURL: URL, urlCommand: UrlCommand) -> Bool { 151 let savedURL = scannedURLs.first { scannedURL in 152 scannedURL.url == passedURL 153 } 154 if savedURL == nil { // doesn't exist yet 155 var save = false 156 switch urlCommand { 157 case .addExchange: save = true 158 case .withdraw: save = true 159 case .withdrawExchange: save = true 160 case .pay: save = true 161 case .payPull: save = true 162 case .payPush: save = true 163 case .payTemplate: save = true 164 165 default: break 166 } 167 if save { 168 let scannedURL = ScannedURL(url: passedURL, command: urlCommand, time: .now) 169 if scannedURLs.count > 5 { 170 self.logger.trace("removing: \(self.scannedURLs.first?.command.rawValue ?? EMPTYSTRING)") 171 scannedURLs.remove(at: 0) 172 } 173 scannedURLs.append(scannedURL) 174 self.logger.trace("saveURL: \(urlCommand.rawValue)") 175 return true 176 } 177 } 178 return false 179 } 180 181 func removeURL(_ passedURL: URL) { 182 scannedURLs.removeAll { scannedURL in 183 scannedURL.url == passedURL 184 } 185 } 186 func removeURLs(after: TimeInterval) { 187 let now = Date.now 188 scannedURLs.removeAll { scannedURL in 189 let timeInterval = now.timeIntervalSince(scannedURL.time) 190 self.logger.trace("timeInterval: \(timeInterval)") 191 return timeInterval > after 192 } 193 } 194 func updateAmount(_ amount: Amount, forSaved url: URL) { 195 if let index = scannedURLs.firstIndex(where: { $0.url == url }) { 196 var savedURL = scannedURLs[index] 197 savedURL.amount = amount 198 scannedURLs[index] = savedURL 199 } 200 } 201 func updateBase(_ baseURL: String, forSaved url: URL) { 202 if let index = scannedURLs.firstIndex(where: { $0.url == url }) { 203 var savedURL = scannedURLs[index] 204 savedURL.baseURL = baseURL 205 scannedURLs[index] = savedURL 206 } 207 } 208 209 210 func startObserving() { 211 let defaults = UserDefaults.standard 212 self.diagnosticModeObservation = defaults.observe(\.diagnosticModeEnabled, options: [.new, .old,.prior,.initial]) { [weak self](_, _) in 213 self?.diagnosticModeEnabled = UserDefaults.standard.diagnosticModeEnabled 214 } 215 } 216 217 func checkInternetConnection() { 218 monitor.pathUpdateHandler = { path in 219 let status = switch path.status { 220 case .satisfied: "active" 221 case .unsatisfied: "inactive" 222 default: "unknown" 223 } 224 self.logger.log("Internet connection is \(status)") 225 #if DEBUG // TODO: checkInternetConnection hintNetworkAvailabilityT 226 DispatchQueue.main.async { 227 if path.status == .unsatisfied { 228 self.isConnected = false 229 Task.detached { 230 await WalletModel.shared.hintNetworkAvailabilityT(false) 231 } 232 } else { 233 self.stopCheckingConnection() 234 self.isConnected = true 235 Task.detached { 236 await WalletModel.shared.hintNetworkAvailabilityT(true) 237 } 238 } 239 } 240 #endif 241 } 242 self.logger.log("Start monitoring internet connection") 243 let queue = DispatchQueue(label: "InternetMonitor") 244 monitor.start(queue: queue) 245 } 246 func stopCheckingConnection() { 247 self.logger.log("Stop monitoring internet connection") 248 monitor.cancel() 249 } 250 251 func printFonts() { 252 for family in UIFont.familyNames { 253 print(family) 254 for names in UIFont.fontNames(forFamilyName: family) { 255 print("== \(names)") 256 } 257 } 258 } 259 260 init() { 261 backendState = .instantiated 262 currencyTicker = 0 263 currencyInfos = [:] 264 exchanges = [] 265 balances = [] 266 // printFonts() 267 // checkInternetConnection() 268 startObserving() 269 } 270 // MARK: - 271 @MainActor 272 @discardableResult 273 func loadBalances(_ stack: CallStack,_ model: WalletModel) async -> Int? { 274 if let reloaded = try? await model.getBalances(stack.push()) { 275 if reloaded != balances { 276 for balance in reloaded { 277 let scope = balance.scopeInfo 278 checkInfo(for: scope, model: model) 279 } 280 self.logger.log("••Got new balances, will redraw") 281 balances = reloaded // redraw 282 } else { 283 self.logger.log("••Same balances, no redraw") 284 } 285 return reloaded.count 286 } 287 return nil 288 } 289 290 func balance(for scope: ScopeInfo) -> Balance? { 291 for balance in balances { 292 if balance.scopeInfo == scope { 293 return balance 294 } 295 } 296 return nil 297 } 298 // MARK: - 299 func exchange(for baseUrl: String) -> Exchange? { 300 for exchange in exchanges { 301 if exchange.exchangeBaseUrl == baseUrl { 302 return exchange 303 } 304 } 305 return nil 306 } 307 308 func info(for scope: ScopeInfo) -> CurrencyInfo? { 309 // return CurrencyInfo.euro() // Fake EUR instead of the real Currency 310 // return CurrencyInfo.francs() // Fake CHF instead of the real Currency 311 return currencyInfos[scope] 312 } 313 func info(for scope: ScopeInfo, _ ticker: Int) -> CurrencyInfo { 314 if ticker != currencyTicker { 315 print(" ❗️Yikes - race condition while getting info for \(scope.currency)") 316 } 317 return info(for: scope) ?? CurrencyInfo.zero(scope.currency) 318 } 319 320 func info2(for currency: String) -> CurrencyInfo? { 321 // return CurrencyInfo.euro() // Fake EUR instead of the real Currency 322 // return CurrencyInfo.francs() // Fake CHF instead of the real Currency 323 for (scope, info) in currencyInfos { 324 if scope.currency == currency { 325 return info 326 } 327 } 328 // logger.log(" ❗️ no info for \(currency)") 329 return nil 330 } 331 func info2(for currency: String, _ ticker: Int) -> CurrencyInfo { 332 if ticker != currencyTicker { 333 print(" ❗️Yikes - race condition while getting info for \(currency)") 334 } 335 return info2(for: currency) ?? CurrencyInfo.zero(currency) 336 } 337 338 func hasInfo(for currency: String) -> Bool { 339 for (scope, info) in currencyInfos { 340 if scope.currency == currency { 341 return true 342 } 343 } 344 // logger.log(" ❗️ no info for \(currency)") 345 return false 346 } 347 348 @MainActor 349 func exchange(for baseUrl: String?, model: WalletModel) async -> Exchange? { 350 if let baseUrl { 351 if let exchange1 = exchange(for: baseUrl) { 352 return exchange1 353 } 354 if let exchange2 = try? await model.getExchangeByUrl(url: baseUrl) { 355 // logger.log(" ❗️ will add \(baseUrl)") 356 exchanges.append(exchange2) 357 return exchange2 358 } 359 } 360 return nil 361 } 362 363 @MainActor 364 func updateInfo(_ scope: ScopeInfo, model: WalletModel) async { 365 if let info = try? await model.getCurrencyInfo(scope: scope) { 366 await setInfo(info, for: scope) 367 // logger.log(" ❗️info set for \(scope.currency)") 368 } 369 } 370 371 func checkCurrencyInfo(for baseUrl: String, model: WalletModel) async -> Exchange? { 372 if let exchange = await exchange(for: baseUrl, model: model) { 373 let scope = exchange.scopeInfo 374 if currencyInfos[scope] == nil { 375 logger.log(" ❗️got no info for \(baseUrl.trimURL) \(scope.currency) -> will update") 376 await updateInfo(scope, model: model) 377 } 378 return exchange 379 } else { 380 // Yikes❗️ TODO: error? 381 } 382 return nil 383 } 384 385 /// called whenever a new currency pops up - will first load the Exchange and then currencyInfos 386 func checkInfo(for scope: ScopeInfo, model: WalletModel) { 387 if currencyInfos[scope] == nil { 388 Task { 389 let exchange = await exchange(for: scope.url, model: model) 390 if let scope2 = exchange?.scopeInfo { 391 let exchangeName = scope2.url ?? "UNKNOWN" 392 logger.log(" ❗️got no info for \(scope.currency) -> will update \(exchangeName.trimURL)") 393 await updateInfo(scope2, model: model) 394 } else { 395 logger.error(" ❗️got no info for \(scope.currency), and couldn't load the exchange info❗️") 396 } 397 } 398 } 399 } 400 401 @MainActor 402 func getInfo(from baseUrl: String, model: WalletModel) async throws -> CurrencyInfo? { 403 let exchange = try await model.getExchangeByUrl(url: baseUrl) 404 let scope = exchange.scopeInfo 405 if let info = info(for: scope) { 406 return info 407 } 408 let info = try await model.getCurrencyInfo(scope: scope) 409 await setInfo(info, for: scope) 410 return info 411 } 412 413 @MainActor 414 func setInfo(_ newInfo: CurrencyInfo, for scope: ScopeInfo) async { 415 await semaphore.wait() 416 defer { semaphore.signal() } 417 418 currencyInfos[scope] = newInfo 419 currencyTicker += 1 // triggers published view update 420 } 421 // MARK: - 422 @MainActor 423 func initWalletCore(_ model: WalletModel, setTesting: Bool) 424 async throws { 425 if backendState == .instantiated { 426 backendState = .initing 427 do { 428 let versionInfo = try await model.initWalletCore(setTesting: setTesting) 429 WalletCore.shared.versionInfo = versionInfo 430 #if !TALER_WALLET 431 if developerMode { 432 try? await model.setConfig(setTesting: true) 433 try? await model.devExperimentT(talerUri: "taler://dev-experiment/default-exchange-demo?val=1") 434 try? await model.devExperimentT(talerUri: "taler://dev-experiment/demo-shortcuts?val=KUDOS:4,KUDOS:8,KUDOS:16,KUDOS:32") 435 try? await model.setConfig(setTesting: false) 436 } 437 #endif 438 backendState = .ready // dismiss the launch animation 439 } catch { // rethrows 440 self.logger.error("\(error.localizedDescription)") 441 backendState = .error // ❗️Yikes app cannot continue 442 throw error 443 } 444 } else { 445 self.logger.fault("Yikes❗️ wallet-core already initialized") 446 } 447 } 448 } 449 450 // MARK: - 451 extension Controller { 452 func openURL(_ url: URL, stack: CallStack) -> UrlCommand { 453 guard let scheme = url.scheme else {return UrlCommand.unknown} 454 #if DEBUG 455 symLog.log(url) 456 #else 457 let host = url.host ?? " <- no command" 458 self.logger.trace("openURL(\(scheme)\(host)") 459 #endif 460 var uncrypted = false 461 var urlCommand = UrlCommand.unknown 462 switch scheme.lowercased() { 463 case "taler+http": 464 uncrypted = true 465 fallthrough 466 case "taler", "ext+taler", "web+taler": 467 urlCommand = talerScheme(url, uncrypted) 468 // case "payto": 469 // messageForSheet = url.absoluteString 470 // return paytoScheme(url) 471 default: 472 self.logger.error("unknown scheme: <\(scheme)>") // should never happen 473 } 474 saveURL(url, urlCommand: urlCommand) 475 return urlCommand 476 } 477 } 478 // MARK: - 479 extension Controller { 480 // func paytoScheme(_ url:URL) -> UrlCommand { 481 // let logItem = "scheme payto:// is not yet implemented" 482 // // TODO: write logItem to somewhere in Debug section of SettingsView 483 // symLog.log(logItem) // TODO: symLog.error(logItem) 484 // return UrlCommand.unknown 485 // } 486 487 func talerScheme(_ url:URL,_ uncrypted: Bool = false) -> UrlCommand { 488 if let command = url.host { 489 if uncrypted { 490 self.logger.trace("uncrypted http: taler://\(command)") 491 // TODO: uncrypted taler+http 492 } 493 switch command.lowercased() { 494 case "withdraw": return .withdraw 495 case "withdraw-exchange": return .withdrawExchange 496 case "add-exchange": return .addExchange 497 case "pay": return .pay 498 case "pay-pull": return .payPull 499 case "pay-push": return .payPush 500 case "pay-template": return .payTemplate 501 case "refund": return .refund 502 #if GNU_TALER 503 case "dev-experiment": return .devExperiment 504 #endif 505 default: 506 self.logger.error("❗️unknown command taler://\(command)") 507 } 508 messageForSheet = command.lowercased() 509 } else { 510 self.logger.error("❗️No taler command") 511 } 512 return .unknown 513 } 514 }