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