taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

Controller.swift (17710B)


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