taler-ios

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

Controller.swift (23983B)


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