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