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