taler-ios

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

Controller.swift (18384B)


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