Controller.swift (23708B)
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 LocalAuthentication 13 import SwiftUI 14 import SymLog 15 import os.log 16 import CoreHaptics 17 import Network 18 import taler_swift 19 20 enum BackendState: Equatable { 21 case none 22 case instantiated 23 case initing 24 case ready 25 case error(EquatableError) 26 27 static func == (lhs: BackendState, rhs: BackendState) -> Bool { 28 switch (lhs, rhs) { 29 case (.none, .none): 30 return true 31 case (.instantiated, .instantiated): 32 return true 33 case (.initing, .initing): 34 return true 35 case (.ready, .ready): 36 return true 37 case (.error(let lhsError), .error(let rhsError)): 38 return lhsError == rhsError 39 default: 40 return false 41 } 42 } 43 } 44 45 enum UrlCommand: String, Codable { 46 case unknown 47 case withdraw 48 case withdrawExchange 49 case addExchange 50 case pay 51 case payPull 52 case payPush 53 case payTemplate 54 case refund 55 #if GNU_TALER || TALER_NIGHTLY 56 case devExperiment 57 #endif 58 59 var isOutgoing: Bool { 60 switch self { 61 case .pay, .payPull, .payTemplate: 62 true 63 default: 64 false 65 } 66 } 67 68 var localizedCommand: String { 69 switch self { 70 case .unknown: String(EMPTYSTRING) 71 case .withdraw, 72 .withdrawExchange: String(localized: "Withdraw", 73 comment: "UrlCommand") 74 case .addExchange: String(localized: "Add payment service", 75 comment: "UrlCommand") 76 case .pay: String(localized: "Pay merchant", 77 comment: "UrlCommand") 78 case .payPull: String(localized: "Pay others", 79 comment: "UrlCommand") 80 case .payPush: String(localized: "Receive", 81 comment: "UrlCommand") 82 case .payTemplate: String(localized: "Pay ...", 83 comment: "UrlCommand") 84 case .refund: String(localized: "Refund", 85 comment: "UrlCommand") 86 #if GNU_TALER || TALER_NIGHTLY 87 case .devExperiment: String("DevExperiment") 88 #endif 89 } 90 } 91 var transactionType: TransactionType { 92 switch self { 93 case .unknown: .dummy 94 case .withdraw: .withdrawal 95 case .withdrawExchange: .withdrawal 96 case .addExchange: .dummy 97 case .pay: .payment 98 case .payPull: .scanPullDebit 99 case .payPush: .scanPushCredit 100 case .payTemplate: .payment 101 case .refund: .refund 102 #if GNU_TALER || TALER_NIGHTLY 103 case .devExperiment: .dummy 104 #endif 105 } 106 } 107 } 108 109 struct ScannedURL: Identifiable { 110 var id: String { 111 url.absoluteString 112 } 113 var url: URL 114 var command: UrlCommand 115 var amount: Amount? 116 var baseURL: String? 117 var scope: ScopeInfo? 118 var time: Date 119 } 120 121 // MARK: - 122 class Controller: ObservableObject { 123 public static let shared = Controller() 124 private let symLog = SymLogC() 125 126 @Published var haveProdBalance: Bool = false 127 @Published var balances: [Balance] = [] 128 @Published var discounts: [TalerToken] = [] 129 @Published var subscriptions: [TalerToken] = [] 130 @Published var defaultExchanges: [DefaultExchange] = [] 131 @Published var scannedURLs: [ScannedURL] = [] 132 133 @Published var backendState: BackendState = .none // only used for launch animation 134 @Published var currencyTicker: Int = 0 // updates whenever a new currency is added 135 @Published var userAction: Int = 0 // make Action button jump 136 137 @Published var isConnected: Bool = true 138 @Published var networkUnavailable: Bool = false 139 @Published var slowConnection: Bool = false 140 @Published var stalledConnection: Bool = false 141 @Published var errorConnection: Bool = false 142 @Published var lastProgressError: RequestProgressError? = nil 143 @Published var lastProgressPhase: RequestProgressPhase? = nil 144 @Published var oimModeActive: Bool = false 145 @Published var oimSheetActive: Bool = false 146 @Published var diagnosticModeEnabled: Bool = false 147 @Published var talerURI: URL? = nil 148 @Published var choicesForPayment: ChoicesForPayment? = nil 149 150 @AppStorage("useHaptics") var useHaptics: Bool = true // extension mustn't define this, so it must be here 151 @AppStorage("playSounds") var playSounds: Bool = false 152 @AppStorage("talerFontIndex") var talerFontIndex: Int = 0 // extension mustn't define this, so it must be here 153 #if DEBUG 154 @AppStorage("developerMode") var developerMode: Bool = true 155 #else 156 @AppStorage("developerMode") var developerMode: Bool = false 157 #endif 158 @AppStorage("deviceTokenAPNs") var deviceTokenAPNs: String? 159 @AppStorage("developDelay") var developDelay: Bool = false 160 let hapticCapability = CHHapticEngine.capabilitiesForHardware() 161 let logger = Logger(subsystem: "net.taler.gnu", category: "Controller") 162 let player = AVQueuePlayer() 163 let semaphore = AsyncSemaphore(value: 1) 164 private var currencyInfos: [ScopeInfo : CurrencyInfo] 165 var exchanges: [Exchange] 166 var messageForSheet: String? = nil 167 var isLoadingChoices: String? = nil 168 169 private let monitor = NWPathMonitor() 170 171 private var diagnosticModeObservation: NSKeyValueObservation? 172 #if OIM 173 private var lastOIMmode: UIDeviceOrientation = .portrait 174 func setOIMmode(for newOrientation: UIDeviceOrientation, _ sheetActive: Bool) { 175 if lastOIMmode == .landscapeRight { 176 if newOrientation == .faceUp { 177 return 178 } 179 } 180 let isLandscapeRight = newOrientation == .landscapeRight 181 lastOIMmode = newOrientation 182 oimSheetActive = sheetActive && isLandscapeRight 183 oimModeActive = sheetActive ? false 184 : isLandscapeRight 185 // print("😱 oimSheetActive = \(oimSheetActive)") 186 } 187 #endif 188 189 func biometryType() -> LABiometryType? { 190 let context = LAContext() 191 var error: NSError? = nil 192 if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { 193 return context.biometryType 194 } 195 // else device has no enabled biometrics 196 #if DEBUG 197 if let error { 198 print(error) 199 } 200 #endif 201 return nil 202 } 203 204 @discardableResult 205 func saveURL(_ passedURL: URL, urlCommand: UrlCommand) -> Bool { 206 let savedURL = scannedURLs.first { scannedURL in 207 scannedURL.url == passedURL 208 } 209 if savedURL == nil { // doesn't exist yet 210 var save = false 211 switch urlCommand { 212 case .addExchange: save = true 213 case .withdraw: save = true 214 case .withdrawExchange: save = true 215 case .pay: save = true 216 case .payPull: save = true 217 case .payPush: save = true 218 case .payTemplate: save = true 219 220 default: break 221 } 222 if save { 223 let scannedURL = ScannedURL(url: passedURL, command: urlCommand, time: .now) 224 if scannedURLs.count > 5 { 225 self.logger.trace("removing: \(self.scannedURLs.first?.command.rawValue ?? EMPTYSTRING)") 226 scannedURLs.remove(at: 0) 227 } 228 scannedURLs.append(scannedURL) 229 self.logger.trace("saveURL: \(urlCommand.rawValue)") 230 return true 231 } 232 } 233 return false 234 } 235 236 func removeURL(_ passedURL: URL) { 237 scannedURLs.removeAll { scannedURL in 238 scannedURL.url == passedURL 239 } 240 } 241 func removeURLs(after: TimeInterval) { 242 let now = Date.now 243 scannedURLs.removeAll { scannedURL in 244 let timeInterval = now.timeIntervalSince(scannedURL.time) 245 self.logger.trace("timeInterval: \(timeInterval)") 246 return timeInterval > after 247 } 248 } 249 func updateAmount(_ amount: Amount, forSaved url: URL) { 250 if let index = scannedURLs.firstIndex(where: { $0.url == url }) { 251 var savedURL = scannedURLs[index] 252 savedURL.amount = amount 253 scannedURLs[index] = savedURL 254 } 255 } 256 func updateBase(_ baseURL: String, forSaved url: URL) { 257 if let index = scannedURLs.firstIndex(where: { $0.url == url }) { 258 var savedURL = scannedURLs[index] 259 savedURL.baseURL = baseURL 260 scannedURLs[index] = savedURL 261 } 262 } 263 264 func startObserving() { 265 let defaults = UserDefaults.standard 266 self.diagnosticModeObservation = defaults.observe(\.diagnosticModeEnabled, options: [.new, .old,.prior,.initial]) { [weak self](_, _) in 267 self?.diagnosticModeEnabled = UserDefaults.standard.diagnosticModeEnabled 268 } 269 } 270 271 func checkInternetConnection() { 272 monitor.pathUpdateHandler = { path in 273 let status = switch path.status { 274 case .satisfied: "active" 275 case .unsatisfied: "inactive" 276 default: "unknown" 277 } 278 self.logger.log("Internet connection is \(status)") 279 #if DEBUG // TODO: checkInternetConnection hintNetworkAvailabilityT 280 DispatchQueue.main.async { 281 if path.status == .unsatisfied { 282 self.isConnected = false 283 Task.detached { 284 await WalletModel.shared.hintNetworkAvailabilityT(false) 285 } 286 } else { 287 self.stopCheckingConnection() 288 self.isConnected = true 289 Task.detached { 290 await WalletModel.shared.hintNetworkAvailabilityT(true) 291 } 292 } 293 } 294 #endif 295 } 296 self.logger.log("Start monitoring internet connection") 297 let queue = DispatchQueue(label: "InternetMonitor") 298 monitor.start(queue: queue) 299 } 300 func stopCheckingConnection() { 301 self.logger.log("Stop monitoring internet connection") 302 monitor.cancel() 303 } 304 305 func printFonts() { 306 for family in UIFont.familyNames { 307 print(family) 308 for names in UIFont.fontNames(forFamilyName: family) { 309 print("== \(names)") 310 } 311 } 312 } 313 314 init() { 315 backendState = .instantiated 316 currencyTicker = 0 317 currencyInfos = [:] 318 exchanges = [] 319 balances = [] 320 discounts = [] 321 subscriptions = [] 322 defaultExchanges = [] 323 // printFonts() 324 // checkInternetConnection() 325 startObserving() 326 } 327 // MARK: - 328 @MainActor 329 @discardableResult 330 func loadBalances(_ stack: CallStack,_ model: WalletModel) async -> Int? { 331 if let response = try? await model.getBalances(stack.push()) { 332 let reloaded = response.balances 333 if reloaded != balances { 334 for balance in reloaded { 335 let scope = balance.scopeInfo 336 checkInfo(for: scope, model: model) 337 } 338 self.logger.log("••Got new balances, will redraw") 339 balances = reloaded // redraw 340 } else { 341 self.logger.log("••Same balances, no redraw") 342 } 343 haveProdBalance = response.haveProdBalance 344 return reloaded.count 345 } 346 return nil 347 } 348 349 func balance(for scope: ScopeInfo) -> Balance? { 350 for balance in balances { 351 if balance.scopeInfo == scope { 352 return balance 353 } 354 } 355 return nil 356 } 357 // MARK: - 358 @MainActor 359 @discardableResult 360 func loadDiscounts(_ stack: CallStack,_ model: WalletModel) async -> Int? { 361 if let response = try? await model.listDiscounts(stack.push()) { 362 let reloaded = response.discounts 363 if reloaded != discounts { 364 self.logger.log("••Got new discounts, will redraw") 365 discounts = reloaded // redraw 366 } else { 367 self.logger.log("••Same discounts, no redraw") 368 } 369 return reloaded.count 370 } 371 return nil 372 } 373 // MARK: - 374 @MainActor 375 @discardableResult 376 func loadSubscriptions(_ stack: CallStack,_ model: WalletModel) async -> Int? { 377 if let response = try? await model.listSubscriptions(stack.push()) { 378 let reloaded = response.subscriptions 379 if reloaded != subscriptions { 380 self.logger.log("••Got new passes, will redraw") 381 subscriptions = reloaded // redraw 382 } else { 383 self.logger.log("••Same passes, no redraw") 384 } 385 return reloaded.count 386 } 387 return nil 388 } 389 // MARK: - 390 @MainActor 391 @discardableResult 392 func loadChoicesForPayment(_ stack: CallStack, 393 _ model: WalletModel, 394 txId: String) async -> Bool { 395 self.logger.log("getChoicesForPayment: \(txId)") 396 if isLoadingChoices != txId { 397 isLoadingChoices = txId 398 if let choiceResponse = try? await model.getChoicesForPayment(txId) { 399 choicesForPayment = choiceResponse 400 isLoadingChoices = nil 401 return true 402 } else { 403 isLoadingChoices = nil 404 self.logger.log("getChoicesForPayment failed: \(txId)") 405 } 406 } else { 407 self.logger.log("getChoicesForPayment already in progress: \(txId)") 408 } 409 return false 410 } 411 // MARK: - 412 func exchange(for baseUrl: String) -> Exchange? { 413 for exchange in exchanges { 414 if exchange.exchangeBaseUrl == baseUrl { 415 return exchange 416 } 417 } 418 return nil 419 } 420 421 func info(for scope: ScopeInfo) -> CurrencyInfo? { 422 // return CurrencyInfo.euro() // Fake EUR instead of the real Currency 423 // return CurrencyInfo.francs() // Fake CHF instead of the real Currency 424 return currencyInfos[scope] 425 } 426 func info(for scope: ScopeInfo, _ ticker: Int) -> CurrencyInfo { 427 if ticker != currencyTicker { 428 print(" ❗️Yikes - race condition while getting info for \(scope.currency)") 429 } 430 return info(for: scope) ?? CurrencyInfo.zero(scope.currency) 431 } 432 433 func info2(for currency: String) -> CurrencyInfo? { 434 // return CurrencyInfo.euro() // Fake EUR instead of the real Currency 435 // return CurrencyInfo.francs() // Fake CHF instead of the real Currency 436 for (scope, info) in currencyInfos { 437 if scope.currency == currency { 438 return info 439 } 440 } 441 // logger.log(" ❗️ no info for \(currency)") 442 return nil 443 } 444 func info2(for currency: String, _ ticker: Int) -> CurrencyInfo { 445 if ticker != currencyTicker { 446 print(" ❗️Yikes - race condition while getting info for \(currency)") 447 } 448 return info2(for: currency) ?? CurrencyInfo.zero(currency) 449 } 450 451 func hasInfo(for currency: String) -> Bool { 452 for (scope, info) in currencyInfos { 453 if scope.currency == currency { 454 return true 455 } 456 } 457 // logger.log(" ❗️ no info for \(currency)") 458 return false 459 } 460 461 @MainActor 462 func exchange(for baseUrl: String?, model: WalletModel) async -> Exchange? { 463 if let baseUrl { 464 if let exchange1 = exchange(for: baseUrl) { 465 return exchange1 466 } 467 if let exchange2 = try? await model.getExchangeByUrl(url: baseUrl) { 468 // logger.log(" ❗️ will add \(baseUrl)") 469 exchanges.append(exchange2) 470 return exchange2 471 } 472 } 473 return nil 474 } 475 476 @MainActor 477 func updateInfo(_ scope: ScopeInfo, model: WalletModel) async { 478 if let info = try? await model.getCurrencyInfo(scope: scope) { 479 await setInfo(info, for: scope) 480 // logger.log(" ❗️info set for \(scope.currency)") 481 } 482 } 483 484 func checkCurrencyInfo(for baseUrl: String, model: WalletModel) async -> Exchange? { 485 if let exchange = await exchange(for: baseUrl, model: model) { 486 let scope = exchange.scopeInfo 487 if currencyInfos[scope] == nil { 488 logger.log(" ❗️got no info for \(baseUrl.trimURL) \(scope.currency) -> will update") 489 await updateInfo(scope, model: model) 490 } 491 return exchange 492 } else { 493 // Yikes❗️ TODO: error? 494 } 495 return nil 496 } 497 498 /// called whenever a new currency pops up - will first load the Exchange and then currencyInfos 499 func checkInfo(for scope: ScopeInfo, model: WalletModel) { 500 if currencyInfos[scope] == nil { 501 Task { 502 let exchange = await exchange(for: scope.url, model: model) 503 if let scope2 = exchange?.scopeInfo { 504 let exchangeName = scope2.url ?? "UNKNOWN" 505 logger.log(" ❗️got no info for \(scope.currency) -> will update \(exchangeName.trimURL)") 506 await updateInfo(scope2, model: model) 507 } else { 508 logger.error(" ❗️got no info for \(scope.currency), and couldn't load the exchange info❗️") 509 } 510 } 511 } 512 } 513 514 @MainActor 515 func getInfo(from baseUrl: String, model: WalletModel) async throws -> CurrencyInfo? { 516 let exchange = try await model.getExchangeByUrl(url: baseUrl) 517 let scope = exchange.scopeInfo 518 if let info = info(for: scope) { 519 return info 520 } 521 let info = try await model.getCurrencyInfo(scope: scope) 522 await setInfo(info, for: scope) 523 return info 524 } 525 526 @MainActor 527 func setInfo(_ newInfo: CurrencyInfo, for scope: ScopeInfo) async { 528 await semaphore.wait() 529 defer { semaphore.signal() } 530 531 currencyInfos[scope] = newInfo 532 currencyTicker += 1 // triggers published view update 533 } 534 // MARK: - 535 @MainActor 536 func initWalletCore(_ model: WalletModel, stage: Bool, setTesting: Bool, delay: TimeInterval) 537 async throws { 538 if backendState == .instantiated { 539 backendState = .initing 540 do { 541 let walletCore = WalletCore.shared 542 let versionInfo = try await model.initWalletCore(setTesting: setTesting) 543 walletCore.versionInfo = versionInfo 544 if developerMode { 545 try await model.setConfig(setTesting: true) 546 if developDelay == true { 547 try await model.devExperimentT( 548 "taler://dev-experiment/start-tc?delay_resp=\(TCDELAY)") 549 } 550 // try await model.devExperimentT("taler://dev-experiment/start-tc?fake_500=0.7") 551 // try await model.devExperimentT("taler://dev-experiment/default-exchange-demo?val=1") 552 try await model.devExperimentT( 553 "taler://dev-experiment/demo-shortcuts?val=KUDOS:4,KUDOS:8,KUDOS:16,KUDOS:32") 554 } 555 defaultExchanges = await model.getDefaultExchanges(stage: stage) 556 if stage, let talerOps = defaultExchanges.first { 557 let stageURI = "taler://withdraw-exchange/exchange.stage.taler-ops.ch" 558 if talerOps.talerUri != stageURI { 559 let stageExc = DefaultExchange(talerUri: stageURI, 560 currency: talerOps.currency, 561 currencySpec: talerOps.currencySpec 562 ) 563 defaultExchanges.insert(stageExc, at: 0) 564 } 565 } 566 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 567 self.backendState = .ready // dismiss the launch animation 568 } 569 } catch { // rethrows 570 self.logger.error("\(error.localizedDescription)") 571 backendState = .error(error as! EquatableError) // ❗️Yikes app cannot continue 572 throw error 573 } 574 } else { 575 self.logger.fault("Yikes❗️ wallet-core already initialized") 576 } 577 } 578 } 579 580 // MARK: - 581 extension Controller { 582 func urlCommand(_ url: URL, stack: CallStack) -> UrlCommand { 583 guard let scheme = url.scheme else {return UrlCommand.unknown} 584 #if DEBUG 585 symLog.log(url) 586 #else 587 let host = url.host ?? " <- no command" 588 self.logger.trace("urlCommand(\(scheme)\(host)") 589 #endif 590 var uncrypted = false 591 var urlCommand = UrlCommand.unknown 592 switch scheme.lowercased() { 593 case "taler+http": 594 uncrypted = true 595 fallthrough 596 case "taler", "ext+taler", "web+taler": 597 urlCommand = talerScheme(url, uncrypted) 598 // case "payto": 599 // messageForSheet = url.absoluteString 600 // return paytoScheme(url) 601 default: 602 self.logger.error("unknown scheme: <\(scheme)>") // should never happen 603 } 604 saveURL(url, urlCommand: urlCommand) 605 return urlCommand 606 } 607 } 608 // MARK: - 609 extension Controller { 610 // func paytoScheme(_ url:URL) -> UrlCommand { 611 // let logItem = "scheme payto:// is not yet implemented" 612 // // TODO: write logItem to somewhere in Debug section of SettingsView 613 // symLog.log(logItem) // TODO: symLog.error(logItem) 614 // return UrlCommand.unknown 615 // } 616 617 func talerScheme(_ url:URL,_ uncrypted: Bool = false) -> UrlCommand { 618 if let command = url.host { 619 if uncrypted { 620 self.logger.trace("uncrypted http: taler://\(command)") 621 // TODO: uncrypted taler+http 622 } 623 switch command.lowercased() { 624 case "withdraw": return .withdraw 625 case "withdraw-exchange": return .withdrawExchange 626 case "add-exchange": return .addExchange 627 case "pay": return .pay 628 case "pay-pull": return .payPull 629 case "pay-push": return .payPush 630 case "pay-template": return .payTemplate 631 case "refund": return .refund 632 #if GNU_TALER || TALER_NIGHTLY 633 case "dev-experiment": return .devExperiment 634 #endif 635 default: 636 self.logger.error("❗️unknown command taler://\(command)") 637 } 638 messageForSheet = command.lowercased() 639 } else { 640 self.logger.error("❗️No taler command") 641 } 642 return .unknown 643 } 644 }