taler-ios

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

Controller.swift (20466B)


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