taler-ios

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

commit 0d3486f224f600aac7942ef14999b2779deb4638
parent c72370e05871f9f95fb7272c40c0aae2c51f3eb2
Author: Jonathan Buchanan <jonathan.russ.buchanan@gmail.com>
Date:   Tue, 23 Aug 2022 15:39:31 -0400

parse withdraw transaction details

Diffstat:
MREADME.md | 2+-
MTaler.xcodeproj/project.pbxproj | 4++++
MTaler/Model/BalancesModel.swift | 21++++++++++-----------
ATaler/Model/TransactionsModel.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
MTaler/Views/BalancesView.swift | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
MTaler/WalletBackend.swift | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtaler-swift/Sources/taler-swift/Time.swift | 10+++++-----
7 files changed, 313 insertions(+), 38 deletions(-)

diff --git a/README.md b/README.md @@ -11,4 +11,4 @@ Before building anything, you should initialize and update the submodules by run $ ./bootstrap -To build the app, open `Taler.xcodeproj` with Xcode. The minimum version of iOS supported is 13.0. This iOS version is compatible on all iPhone models at least as new as the iPhone 6S. +To build the app, open `Taler.xcodeproj` with Xcode. The minimum version of iOS supported is 14.0. This iOS version is compatible on all iPhone models at least as new as the iPhone 6S. diff --git a/Taler.xcodeproj/project.pbxproj b/Taler.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ AB1F87C82887C94700AB82A0 /* TalerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1F87C72887C94700AB82A0 /* TalerApp.swift */; }; AB1F87CA2887D2F400AB82A0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1F87C92887D2F400AB82A0 /* ContentView.swift */; }; + AB32199128B18859008AAC75 /* TransactionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB32199028B18859008AAC75 /* TransactionsModel.swift */; }; AB4C534A28AC21C9003004F7 /* BalancesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4C534928AC21C9003004F7 /* BalancesView.swift */; }; AB4C534C28AC25FC003004F7 /* BalancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4C534B28AC25FC003004F7 /* BalancesModel.swift */; }; AB69F9FA28AAED53005CCC2E /* WithdrawModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB69F9F928AAED53005CCC2E /* WithdrawModel.swift */; }; @@ -60,6 +61,7 @@ /* Begin PBXFileReference section */ AB1F87C72887C94700AB82A0 /* TalerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TalerApp.swift; sourceTree = "<group>"; }; AB1F87C92887D2F400AB82A0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; + AB32199028B18859008AAC75 /* TransactionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsModel.swift; sourceTree = "<group>"; }; AB4C534928AC21C9003004F7 /* BalancesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalancesView.swift; sourceTree = "<group>"; }; AB4C534B28AC25FC003004F7 /* BalancesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalancesModel.swift; sourceTree = "<group>"; }; AB69F9F928AAED53005CCC2E /* WithdrawModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithdrawModel.swift; sourceTree = "<group>"; }; @@ -129,6 +131,7 @@ isa = PBXGroup; children = ( ABB33066289C658900668B42 /* BackendManager.swift */, + AB32199028B18859008AAC75 /* TransactionsModel.swift */, ABB33064289C5BBB00668B42 /* ExchangeManager.swift */, ABC4AC3E28A473070047A56F /* PendingManager.swift */, AB69F9F928AAED53005CCC2E /* WithdrawModel.swift */, @@ -390,6 +393,7 @@ ABB33065289C5BBB00668B42 /* ExchangeManager.swift in Sources */, AB4C534A28AC21C9003004F7 /* BalancesView.swift in Sources */, D1D65B9826992E4600C1012A /* WalletBackend.swift in Sources */, + AB32199128B18859008AAC75 /* TransactionsModel.swift in Sources */, ABB762AD2891059600E88634 /* SettingsView.swift in Sources */, ABC4AC3B28A4619C0047A56F /* PendingView.swift in Sources */, ABC4AC3F28A473070047A56F /* PendingManager.swift in Sources */, diff --git a/Taler/Model/BalancesModel.swift b/Taler/Model/BalancesModel.swift @@ -17,33 +17,32 @@ import Foundation class BalancesModel: ObservableObject { - enum State { - case begin - case loading - case loaded([Balance]) - } - var backend: WalletBackend - @Published var state: State + + @Published var loading: Bool = false + @Published var balances: [Balance]? init(backend: WalletBackend) { self.backend = backend - self.state = .begin } func getBalances() { - self.state = .loading + self.loading = true let req = WalletBackendGetBalancesRequest() backend.sendFormattedRequest(request: req) { response, err in // TODO: Use Combine instead DispatchQueue.main.async { + self.loading = false if let res = response { - self.state = .loaded(res.balances) + self.balances = res.balances } else { // TODO: Handle error. - self.state = .begin } } } } + + func getTransactionsModel() -> TransactionsModel { + return TransactionsModel(backend: self.backend, currency: nil) + } } diff --git a/Taler/Model/TransactionsModel.swift b/Taler/Model/TransactionsModel.swift @@ -0,0 +1,48 @@ +/* + * This file is part of GNU Taler + * (C) 2022 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import Foundation + +class TransactionsModel: ObservableObject { + var backend: WalletBackend + var currency: String? + + @Published var loading: Bool = false + @Published var transactions: [Transaction]? + + init(backend: WalletBackend, currency: String?) { + self.backend = backend + self.currency = currency + } + + func loadTransactions(searchString: String? = nil) { + self.loading = true + let req = WalletBackendGetTransactionsRequest(currency: self.currency, + search: searchString) + backend.sendFormattedRequest(request: req) { response, err in + // TODO: Use Combine instead + DispatchQueue.main.async { + self.loading = false + if let res = response { + print("x") + self.transactions = res.transactions + } else { + // TODO: Handle error. + } + } + } + } +} diff --git a/Taler/Views/BalancesView.swift b/Taler/Views/BalancesView.swift @@ -16,6 +16,47 @@ import SwiftUI +struct TransactionsView: View { + @ObservedObject var model: TransactionsModel + + var body: some View { + VStack { + if model.transactions == nil { + ProgressView() + .onAppear { + model.loadTransactions() + } + } else if model.loading { + ProgressView() + } else { + List(model.transactions!, id: \.self) { tx in + VStack { + Text("Transaction: \(tx.transactionId)") + } + } + Text("Loaded") + /*VStack { + Text("Balances") + NavigationLink { + TransactionsView(model: self.balancesModel.getTransactionsModel()) + } label: { + Text("Transactions") + } + + } + .padding(16) + .navigationTitle("Balances") + .navigationBarItems( + leading: Button(action: self.showSidebar, label: { + Image(systemName: "line.3.horizontal") + }) + )*/ + } + } + .navigationTitle("Transactions") + } +} + struct BalancesView: View { @ObservedObject var balancesModel: BalancesModel @EnvironmentObject var backend: BackendManager @@ -23,19 +64,18 @@ struct BalancesView: View { var body: some View { NavigationView { - switch balancesModel.state { - case .begin: - EmptyView() - .onAppear(perform: { - balancesModel.getBalances() - }) + if balancesModel.balances == nil { + ProgressView() .navigationTitle("Balances") .navigationBarItems( leading: Button(action: self.showSidebar, label: { Image(systemName: "line.3.horizontal") }) ) - case .loading: + .onAppear { + balancesModel.getBalances() + } + } else if balancesModel.loading { ProgressView() .navigationTitle("Balances") .navigationBarItems( @@ -43,9 +83,15 @@ struct BalancesView: View { Image(systemName: "line.3.horizontal") }) ) - case .loaded(let balances): + } else { VStack { Text("Balances") + NavigationLink { + TransactionsView(model: self.balancesModel.getTransactionsModel()) + } label: { + Text("Transactions") + } + } .padding(16) .navigationTitle("Balances") @@ -57,8 +103,4 @@ struct BalancesView: View { } } } - - /*init(showSidebar: @escaping () -> Void) { - self.showSidebar = showSidebar - }*/ } diff --git a/Taler/WalletBackend.swift b/Taler/WalletBackend.swift @@ -202,22 +202,203 @@ enum TransactionType: Codable { } } -/// An error associated with a transaction. -struct TransactionError: Codable { - var ec: Int - var hint: String? - //var details: Any? +enum TransactionDecodingError: Error { + case invalidStringValue +} + +/// Details for a manual withdrawal. +struct ManualWithdrawalDetails: Codable { + /// The payto URIs that the exchange supports. + var exchangePaytoUris: [String] + + /// The public key of the newly created reserve. + var reservePub: String +} + +/// Details for a bank-integrated withdrawal. +struct BankIntegratedWithdrawalDetails: Codable { + /// Whether the bank has confirmed the withdrawal. + var confirmed: Bool + + /// URL for user-initiated confirmation + var bankConfirmationUrl: String? +} + +/// A withdrawal transaction. +struct TransactionWithdrawal: Decodable { + enum WithdrawalDetails { + case manual(ManualWithdrawalDetails) + case bankIntegrated(BankIntegratedWithdrawalDetails) + } + + /// The exchange that was withdrawn from. + var exchangeBaseUrl: String + + /// The amount of the withdrawal, including fees. + var amountRaw: Amount + + /// The amount that will be added to the withdrawer's account. + var amountEffective: Amount + + /// The details of the withdrawal. + var withdrawalDetails: WithdrawalDetails + + init(from decoder: Decoder) throws { + enum CodingKeys: String, CodingKey { + case exchangeBaseUrl + case amountRaw + case amountEffective + case withdrawalDetails + case type + case exchangePaytoUris + case reservePub + case confirmed + case bankConfirmationUrl + } + + let value = try decoder.container(keyedBy: CodingKeys.self) + self.exchangeBaseUrl = try value.decode(String.self, forKey: .exchangeBaseUrl) + self.amountRaw = try value.decode(Amount.self, forKey: .amountRaw) + self.amountEffective = try value.decode(Amount.self, forKey: .amountEffective) + + let detail = try value.nestedContainer(keyedBy: CodingKeys.self, forKey: .withdrawalDetails) + let detailType = try detail.decode(String.self, forKey: .type) + if detailType == "manual-transfer" { + let paytoUris = try detail.decode([String].self, forKey: .exchangePaytoUris) + let reservePub = try detail.decode(String.self, forKey: .reservePub) + let manual = ManualWithdrawalDetails(exchangePaytoUris: paytoUris, reservePub: reservePub) + self.withdrawalDetails = .manual(manual) + } else if detailType == "taler-bank-integration-api" { + let confirmed = try detail.decode(Bool.self, forKey: .confirmed) + var bankConfirmationUrl: String? = nil + if detail.contains(.bankConfirmationUrl) { + bankConfirmationUrl = try detail.decode(String.self, forKey: .bankConfirmationUrl) + } + let bankDetails = BankIntegratedWithdrawalDetails(confirmed: confirmed, bankConfirmationUrl: bankConfirmationUrl) + self.withdrawalDetails = .bankIntegrated(bankDetails) + } else { + throw TransactionDecodingError.invalidStringValue + } + } +} + +/// A payment transaction. +struct TransactionPayment: Codable { + /// Additional information about the payment. + // TODO + + /// An identifier for the payment. + var proposalId: String + + /// The current status of the payment. + // TODO + + /// The amount that must be paid. + var amountRaw: Amount + + /// The amount that was paid. + var amountEffective: Amount +} + +/// A refund transaction. +struct TransactionRefund: Codable { + /// Identifier for the refund. + var refundedTransactionId: String + + /// Additional information about the refund + // TODO + + /// The amount that couldn't be applied because refund permissions expired. + var amountInvalid: Amount + + /// The amount refunded by the merchant. + var amountRaw: Amount + + /// The amount paid to the wallet after fees. + var amountEffective: Amount +} + +/// A tip transaction. +struct TransactionTip: Codable { + /// The current status of the tip. + // TODO + + /// The exchange that the tip will be withdrawn from + var exchangeBaseUrl: String + + /// More information about the merchant sending the tip. + // TODO + + /// The raw amount of the tip without fees. + var amountRaw: Amount + + /// The amount added to the recipient's wallet. + var amountEffective: Amount +} + +/// A refresh transaction. +struct TransactionRefresh: Codable { + /// The exchange that the coins are refreshed with. + var exchangeBaseUrl: String + + /// The raw amount to refresh. + var amountRaw: Amount + + /// The amount to be paid as fees for the refresh. + var amountEffective: Amount } /// A wallet transaction. -struct Transaction: Codable { +struct Transaction: Decodable, Hashable { + enum TransactionDetail { + case withdrawal(TransactionWithdrawal) + } + var transactionId: String - var type: TransactionType var timestamp: Timestamp var pending: Bool - var error: TransactionError? + var error: AnyCodable? var amountRaw: Amount var amountEffective: Amount + var detail: TransactionDetail + + init(from decoder: Decoder) throws { + enum CodingKeys: String, CodingKey { + case transactionId + case timestamp + case pending + case error + case amountRaw + case amountEffective + case type + } + + let value = try decoder.container(keyedBy: CodingKeys.self) + self.transactionId = try value.decode(String.self, forKey: .transactionId) + self.timestamp = try value.decode(Timestamp.self, forKey: .timestamp) + self.pending = try value.decode(Bool.self, forKey: .pending) + if value.contains(.error) { + self.error = try value.decode(AnyCodable.self, forKey: .error) + } + self.amountRaw = try value.decode(Amount.self, forKey: .amountRaw) + self.amountEffective = try value.decode(Amount.self, forKey: .amountEffective) + + let type = try value.decode(String.self, forKey: .type) + if type == "withdrawal" { + let withdrawDetail = try TransactionWithdrawal.init(from: decoder) + self.detail = .withdrawal(withdrawDetail) + } else { + throw TransactionDecodingError.invalidStringValue + } + } + + static func == (lhs: Transaction, rhs: Transaction) -> Bool { + return lhs.transactionId == rhs.transactionId + } + + func hash(into hasher: inout Hasher) { + transactionId.hash(into: &hasher) + } } /// A request to get the transactions in the wallet's history. @@ -231,7 +412,7 @@ struct WalletBackendGetTransactionsRequest: WalletBackendFormattedRequest { } struct Response: Decodable { - + var transactions: [Transaction] } func operation() -> String { @@ -901,6 +1082,7 @@ class WalletBackend: IonoMessageHandler { } func handleMessage(message: String) { + print(message) do { guard let messageData = message.data(using: .utf8) else { throw WalletBackendError.deserializationError } let data = try JSONSerialization.jsonObject(with: messageData, options: .allowFragments) as? [String : Any] diff --git a/taler-swift/Sources/taler-swift/Time.swift b/taler-swift/Sources/taler-swift/Time.swift @@ -30,15 +30,15 @@ public enum Timestamp: Codable, Equatable { case never enum CodingKeys: String, CodingKey { - case t_ms = "t_ms" + case t_s = "t_s" } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) do { - self = Timestamp.milliseconds(try container.decode(UInt64.self, forKey: .t_ms)) + self = Timestamp.milliseconds(try container.decode(UInt64.self, forKey: .t_s) * 1000) } catch { - let stringValue = try container.decode(String.self, forKey: .t_ms) + let stringValue = try container.decode(String.self, forKey: .t_s) if stringValue == "never" { self = Timestamp.never } else { @@ -55,9 +55,9 @@ public enum Timestamp: Codable, Equatable { var value = encoder.container(keyedBy: CodingKeys.self) switch self { case .milliseconds(let t_ms): - try value.encode(t_ms, forKey: .t_ms) + try value.encode(t_ms / 1000, forKey: .t_s) case .never: - try value.encode("never", forKey: .t_ms) + try value.encode("never", forKey: .t_s) } } }