taler-ios

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

Controller.swift (22031B)


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