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