taler-ios

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

Controller.swift (23708B)


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