taler-ios

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

Controller.swift (18758B)


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