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