Implemented that entered tan now can directly be set on TanChallenge, therefore no need of callback anymore

This commit is contained in:
dankito 2022-02-19 15:15:23 +01:00
parent 54c430af2b
commit b74b165974
28 changed files with 701 additions and 73 deletions

View File

@ -10,6 +10,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import net.codinux.banking.fints4k.android.adapter.AccountTransactionsListRecyclerAdapter
import net.codinux.banking.fints4k.android.databinding.FragmentFirstBinding
import net.codinux.banking.fints4k.android.dialogs.EnterTanDialog
/**
* A simple [Fragment] subclass as the default destination in the navigation.
@ -24,14 +25,10 @@ class FirstFragment : Fragment() {
// onDestroyView.
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentFirstBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -43,8 +40,14 @@ class FirstFragment : Fragment() {
adapter = accountTransactionsAdapter
}
val presenter = Presenter() // TODO: inject
presenter.enterTanCallback = { tanChallenge ->
EnterTanDialog().show(tanChallenge, activity!!)
}
// TODO: set your credentials here
Presenter().retrieveAccountData("", "", "", "") { response ->
presenter.retrieveAccountData("", "", "", "") { response ->
if (response.successful) {
accountTransactionsAdapter.items = response.retrievedData.flatMap { it.bookedTransactions }
}

View File

@ -8,6 +8,7 @@ import kotlinx.datetime.LocalDate
import net.dankito.banking.fints.FinTsClientDeprecated
import net.dankito.banking.fints.callback.SimpleFinTsClientCallback
import net.dankito.banking.fints.model.AddAccountParameter
import net.dankito.banking.fints.model.TanChallenge
import net.dankito.banking.fints.response.client.AddAccountResponse
import net.dankito.utils.multiplatform.extensions.millisSinceEpochAtSystemDefaultTimeZone
import org.slf4j.LoggerFactory
@ -15,7 +16,7 @@ import java.math.BigDecimal
import java.text.DateFormat
import java.util.*
class Presenter {
open class Presenter {
companion object {
val ValueDateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
@ -23,11 +24,17 @@ class Presenter {
private val log = LoggerFactory.getLogger(Presenter::class.java)
}
private val fintsClient = FinTsClientDeprecated(SimpleFinTsClientCallback())
private val fintsClient = FinTsClientDeprecated(SimpleFinTsClientCallback { challenge -> enterTan(challenge) })
open var enterTanCallback: ((TanChallenge) -> Unit)? = null
open protected fun enterTan(tanChallenge: TanChallenge) {
enterTanCallback?.invoke(tanChallenge) ?: run { tanChallenge.userDidNotEnterTan() }
}
fun retrieveAccountData(bankCode: String, customerId: String, pin: String, finTs3ServerAddress: String, retrievedResult: (AddAccountResponse) -> Unit) {
open fun retrieveAccountData(bankCode: String, customerId: String, pin: String, finTs3ServerAddress: String, retrievedResult: (AddAccountResponse) -> Unit) {
GlobalScope.launch(Dispatchers.IO) {
val response = fintsClient.addAccountAsync(AddAccountParameter(bankCode, customerId, pin, finTs3ServerAddress))
log.info("Retrieved response from ${response.bank.bankName} for ${response.bank.customerName}")

View File

@ -0,0 +1,140 @@
package net.codinux.banking.fints4k.android.dialogs
import android.graphics.BitmapFactory
import android.os.Bundle
import android.os.Handler
import android.text.InputFilter
import android.text.InputType
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentActivity
import net.codinux.banking.fints4k.android.Presenter
import net.codinux.banking.fints4k.android.R
import net.dankito.banking.fints.model.FlickerCodeTanChallenge
import net.dankito.banking.fints.model.ImageTanChallenge
import net.dankito.banking.fints.model.TanChallenge
import net.dankito.utils.android.extensions.getSpannedFromHtml
import net.dankito.utils.android.extensions.show
open class EnterTanDialog : DialogFragment() {
companion object {
const val DialogTag = "EnterTanDialog"
}
protected lateinit var tanChallenge: TanChallenge
open fun show(tanChallenge: TanChallenge, activity: FragmentActivity) {
this.tanChallenge = tanChallenge
setStyle(STYLE_NORMAL, R.style.FullscreenDialogWithStatusBar)
show(activity.supportFragmentManager, DialogTag)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val rootView = inflater.inflate(R.layout.dialog_enter_tan, container, false)
setupUI(rootView)
return rootView
}
protected open fun setupUI(rootView: View) {
setupTanView(rootView)
setupEnteringTan(rootView)
rootView.findViewById<TextView>(R.id.txtvwMessageToShowToUser).text = tanChallenge.messageToShowToUser.getSpannedFromHtml()
rootView.findViewById<Button>(R.id.btnCancel).setOnClickListener { enteringTanDone(null) }
rootView.findViewById<Button>(R.id.btnEnteringTanDone).setOnClickListener {
enteringTanDone(rootView.findViewById<EditText>(R.id.edtxtEnteredTan).text.toString())
}
}
protected open fun setupTanView(rootView: View) {
if (tanChallenge is FlickerCodeTanChallenge) {
// setupFlickerCodeTanView(rootView)
}
else if (tanChallenge is ImageTanChallenge) {
setupImageTanView(rootView)
}
}
protected open fun setupEnteringTan(rootView: View) {
val edtxtEnteredTan = rootView.findViewById<EditText>(R.id.edtxtEnteredTan)
if (tanChallenge.tanMethod.isNumericTan) {
edtxtEnteredTan.inputType = InputType.TYPE_CLASS_NUMBER
}
tanChallenge.tanMethod.maxTanInputLength?.let { maxInputLength ->
edtxtEnteredTan.filters = arrayOf<InputFilter>(InputFilter.LengthFilter(maxInputLength))
}
edtxtEnteredTan.setOnKeyListener { _, keyCode, _ ->
if (keyCode == KeyEvent.KEYCODE_ENTER) {
enteringTanDone(edtxtEnteredTan.text.toString())
return@setOnKeyListener true
}
false
}
}
protected open fun setupImageTanView(rootView: View) {
val tanImageView = rootView.findViewById<ImageView>(R.id.tanImageView)
tanImageView.show()
val decodedImage = (tanChallenge as ImageTanChallenge).image
if (decodedImage.decodingSuccessful) {
val bitmap = BitmapFactory.decodeByteArray(decodedImage.imageBytes, 0, decodedImage.imageBytes.size)
tanImageView.setImageBitmap(bitmap)
}
else {
showDecodingTanChallengeFailedErrorDelayed(decodedImage.decodingError)
}
}
/**
* This method gets called right on start up before dialog is shown -> Alert would get displayed before dialog and
* therefore covered by dialog -> delay displaying alert.
*/
protected open fun showDecodingTanChallengeFailedErrorDelayed(error: Exception?) {
val handler = Handler()
handler.postDelayed({ showDecodingTanChallengeFailedError(error) }, 500)
}
protected open fun showDecodingTanChallengeFailedError(error: Exception?) {
activity?.let { context ->
AlertDialog.Builder(context)
.setMessage(context.getString(R.string.dialog_enter_tan_error_could_not_decode_tan_image, error?.localizedMessage))
.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() }
.show()
}
}
protected open fun enteringTanDone(enteredTan: String?) {
if (enteredTan != null) {
tanChallenge.userEnteredTan(enteredTan)
} else {
tanChallenge.userDidNotEnterTan()
}
dismiss()
}
}

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/dialog_enter_tan_padding"
android:isScrollContainer="true"
>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<ImageView
android:id="@+id/tanImageView"
android:layout_width="match_parent"
android:layout_height="350dp"
android:layout_gravity="center"
android:gravity="center_vertical"
android:layout_marginBottom="@dimen/dialog_enter_tan_margin_before_enter_tan"
android:visibility="gone"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/dialog_enter_tan_tan_description_label"
/>
<TextView
android:id="@+id/txtvwMessageToShowToUser"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</TextView>
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="@dimen/dialog_enter_tan_enter_tan_height"
android:layout_marginBottom="@dimen/dialog_enter_tan_enter_tan_margin_bottom"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:gravity="center_vertical"
android:textStyle="bold"
android:text="@string/dialog_enter_tan_enter_tan"
/>
<EditText
android:id="@+id/edtxtEnteredTan"
android:layout_width="@dimen/dialog_enter_tan_enter_tan_width"
android:layout_height="match_parent"
android:layout_marginLeft="@dimen/dialog_enter_tan_enter_tan_margin_left"
android:layout_marginStart="@dimen/dialog_enter_tan_enter_tan_margin_left"
/>
</LinearLayout>
<RelativeLayout
android:id="@+id/lytButtonBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<Button
android:id="@+id/btnEnteringTanDone"
android:layout_width="@dimen/dialog_enter_tan_buttons_width"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"
style="?android:attr/buttonBarButtonStyle"
android:text="@android:string/ok"
/>
<Button
android:id="@+id/btnCancel"
android:layout_width="@dimen/dialog_enter_tan_buttons_width"
android:layout_height="wrap_content"
android:layout_toLeftOf="@+id/btnEnteringTanDone"
android:layout_toStartOf="@+id/btnEnteringTanDone"
style="?android:attr/buttonBarButtonStyle"
android:text="@android:string/cancel"
/>
</RelativeLayout>
</LinearLayout>
</ScrollView>

View File

@ -15,4 +15,12 @@
<dimen name="list_item_account_transaction_transaction_text_margin_left_and_right">4dp</dimen>
<dimen name="list_item_account_transaction_other_party_name_margin_top_and_bottom">6dp</dimen>
<dimen name="dialog_enter_tan_padding">4dp</dimen>
<dimen name="dialog_enter_tan_margin_before_enter_tan">6dp</dimen>
<dimen name="dialog_enter_tan_enter_tan_height">50dp</dimen>
<dimen name="dialog_enter_tan_enter_tan_width">150dp</dimen>
<dimen name="dialog_enter_tan_enter_tan_margin_left">6dp</dimen>
<dimen name="dialog_enter_tan_enter_tan_margin_bottom">8dp</dimen>
<dimen name="dialog_enter_tan_buttons_width">120dp</dimen>
</resources>

View File

@ -9,4 +9,10 @@
<string name="hello_first_fragment">Hello first fragment</string>
<string name="hello_second_fragment">Hello second fragment. Arg: %1$s</string>
<string name="dialog_enter_tan_tan_description_label">Hint from your bank:</string>
<string name="dialog_enter_tan_enter_tan">Enter TAN:</string>
<string name="dialog_enter_tan_error_could_not_decode_tan_image">Could not decode flicker code or QR code / PhotoTan. Most likely an internal error:\n%s.</string>
</resources>

View File

@ -1,8 +1,6 @@
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.dankito.banking.fints.FinTsClientDeprecated
import net.dankito.banking.fints.model.AccountTransaction
import net.dankito.banking.fints.model.AddAccountParameter
import net.dankito.banking.fints.model.*
import react.RBuilder
import react.RComponent
import react.Props
@ -11,10 +9,10 @@ import react.dom.*
import styled.styledDiv
external interface AccountTransactionsViewProps : Props {
var client: FinTsClientDeprecated
var presenter: Presenter
}
data class AccountTransactionsViewState(val balance: String, val transactions: Collection<AccountTransaction>) : State
data class AccountTransactionsViewState(val balance: String, val transactions: Collection<AccountTransaction>, val enterTanChallenge: TanChallenge? = null) : State
@JsExport
class AccountTransactionsView(props: AccountTransactionsViewProps) : RComponent<AccountTransactionsViewProps, AccountTransactionsViewState>(props) {
@ -23,19 +21,31 @@ class AccountTransactionsView(props: AccountTransactionsViewProps) : RComponent<
init {
state = AccountTransactionsViewState("", listOf())
props.presenter.enterTanCallback = { setState(AccountTransactionsViewState(state.balance, state.transactions, it)) }
// due to CORS your bank's servers can not be requested directly from browser -> set a CORS proxy url in main.kt
// TODO: set your credentials here
GlobalScope.launch {
val response = props.client.addAccountAsync(AddAccountParameter("", "", "", ""))
if (response.successful) {
val balance = response.retrievedData.sumOf { it.balance?.amount?.string?.replace(',', '.')?.toDoubleOrNull() ?: 0.0 } // i know, double is not an appropriate data type for amounts
props.presenter.retrieveAccountData("", "", "", "") { response ->
if (response.successful) {
val balance = response.retrievedData.sumOf { it.balance?.amount?.string?.replace(',', '.')?.toDoubleOrNull() ?: 0.0 } // i know, double is not an appropriate data type for amounts
setState(AccountTransactionsViewState(balance.toString() + " " + (response.retrievedData.firstOrNull()?.balance?.currency ?: ""), response.retrievedData.flatMap { it.bookedTransactions }))
setState(AccountTransactionsViewState(balance.toString() + " " + (response.retrievedData.firstOrNull()?.balance?.currency ?: ""), response.retrievedData.flatMap { it.bookedTransactions }, state.enterTanChallenge))
}
}
}
}
override fun RBuilder.render() {
state.enterTanChallenge?.let { challenge ->
child(EnterTanView::class) {
attrs {
presenter = props.presenter
tanChallenge = challenge
}
}
}
p {
+"Saldo: ${state.balance}"
}

View File

@ -0,0 +1,69 @@
import io.ktor.util.encodeBase64
import kotlinx.html.InputType
import kotlinx.html.js.onChangeFunction
import kotlinx.html.style
import net.dankito.banking.fints.model.ImageTanChallenge
import net.dankito.banking.fints.model.TanChallenge
import org.w3c.dom.HTMLInputElement
import react.Props
import react.RBuilder
import react.RComponent
import react.State
import react.dom.*
external interface EnterTanViewProps : Props {
var presenter: Presenter
var tanChallenge: TanChallenge
}
data class EnterTanViewState(val enteredTan: String? = null) : State
@JsExport
class EnterTanView(props: EnterTanViewProps) : RComponent<EnterTanViewProps, EnterTanViewState>(props) {
override fun RBuilder.render() {
p {
+"Enter TAN:"
}
if (props.tanChallenge is ImageTanChallenge) {
val tanImage = (props.tanChallenge as ImageTanChallenge).image
if (tanImage.decodingSuccessful) {
val base64Encoded = tanImage.imageBytes.encodeBase64()
img(src = "data:${tanImage.mimeType};base64, $base64Encoded") { }
}
}
p {
props.tanChallenge.messageToShowToUser
}
div {
span { +"TAN:" }
input {
attrs {
type = InputType.text
onChangeFunction = { event ->
val enteredTan = (event.target as HTMLInputElement).value
setState(EnterTanViewState(enteredTan))
}
}
}
button {
span { +"Done" }
attrs {
onMouseUp = {
state.enteredTan?.let {
props.tanChallenge.userEnteredTan(it)
} ?: run { props.tanChallenge.userDidNotEnterTan() }
}
}
}
}
}
}

View File

@ -0,0 +1,45 @@
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.dankito.banking.fints.FinTsClientDeprecated
import net.dankito.banking.fints.callback.SimpleFinTsClientCallback
import net.dankito.banking.fints.model.AddAccountParameter
import net.dankito.banking.fints.model.TanChallenge
import net.dankito.banking.fints.response.client.AddAccountResponse
import net.dankito.banking.fints.webclient.KtorWebClient
import net.dankito.banking.fints.webclient.ProxyingWebClient
import net.dankito.utils.multiplatform.log.LoggerFactory
open class Presenter {
companion object {
private val log = LoggerFactory.getLogger(Presenter::class)
}
// to circumvent CORS we have to use a CORS proxy like the SampleApplications.CorsProxy Application.kt or
// https://github.com/Rob--W/cors-anywhere. Set CORS proxy's URL here
protected open val fintsClient = FinTsClientDeprecated(SimpleFinTsClientCallback { challenge -> enterTan(challenge) },
ProxyingWebClient("http://localhost:8082/", KtorWebClient()))
open var enterTanCallback: ((TanChallenge) -> Unit)? = null
open protected fun enterTan(tanChallenge: TanChallenge) {
enterTanCallback?.invoke(tanChallenge) ?: run { tanChallenge.userDidNotEnterTan() }
}
open fun retrieveAccountData(bankCode: String, customerId: String, pin: String, finTs3ServerAddress: String, retrievedResult: (AddAccountResponse) -> Unit) {
GlobalScope.launch(Dispatchers.Unconfined) {
val response = fintsClient.addAccountAsync(AddAccountParameter(bankCode, customerId, pin, finTs3ServerAddress))
log.info("Retrieved response from ${response.bank.bankName} for ${response.bank.customerName}")
withContext(Dispatchers.Main) {
retrievedResult(response)
}
}
}
}

View File

@ -11,9 +11,7 @@ fun main() {
render(document.getElementById("root")!!) {
child(AccountTransactionsView::class) {
attrs {
// to circumvent CORS we have to use a CORS proxy like the SampleApplications.CorsProxy Application.kt or
// https://github.com/Rob--W/cors-anywhere. Set CORS proxy's URL here
client = FinTsClientDeprecated(SimpleFinTsClientCallback(), ProxyingWebClient("http://localhost:8082/", KtorWebClient()))
presenter = Presenter()
}
}
}

View File

@ -14,6 +14,7 @@
36266AE427BC7776008F3B00 /* Presenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36266AE327BC7776008F3B00 /* Presenter.swift */; };
36266AE727BC7801008F3B00 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36266AE627BC7801008F3B00 /* ViewExtensions.swift */; };
36266AEA27BC85D8008F3B00 /* UrlSessionWebClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36266AE927BC85D8008F3B00 /* UrlSessionWebClient.swift */; };
36266AEC27C19146008F3B00 /* EnterTanDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36266AEB27C19146008F3B00 /* EnterTanDialog.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -25,6 +26,7 @@
36266AE327BC7776008F3B00 /* Presenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presenter.swift; sourceTree = "<group>"; };
36266AE627BC7801008F3B00 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = "<group>"; };
36266AE927BC85D8008F3B00 /* UrlSessionWebClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlSessionWebClient.swift; sourceTree = "<group>"; };
36266AEB27C19146008F3B00 /* EnterTanDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterTanDialog.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -80,6 +82,7 @@
isa = PBXGroup;
children = (
36266AE627BC7801008F3B00 /* ViewExtensions.swift */,
36266AEB27C19146008F3B00 /* EnterTanDialog.swift */,
);
path = ui;
sourceTree = "<group>";
@ -183,6 +186,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
36266AEC27C19146008F3B00 /* EnterTanDialog.swift in Sources */,
36266AE427BC7776008F3B00 /* Presenter.swift in Sources */,
36266AD727BC6F72008F3B00 /* ContentView.swift in Sources */,
36266AEA27BC85D8008F3B00 /* UrlSessionWebClient.swift in Sources */,

View File

@ -6,7 +6,7 @@ struct ContentView: View {
@State var transactions: [AccountTransaction] = []
private let presenter = Presenter()
@EnvironmentObject private var presenter: Presenter
var body: some View {

View File

@ -1,13 +1,23 @@
import SwiftUI
import fints4k
class Presenter {
class Presenter : ObservableObject {
private let fintsClient = FinTsClientDeprecated(callback: SimpleFinTsClientCallback(), webClient: UrlSessionWebClient())
// var enterTanCallback: ((TanChallenge) -> Void)? = nil
// Swift, you're so stupid! It seems to be impossible to initialize SimpleFinTsClientCallback here so that enterTanCallback gets called if set
private var fintsClient = iOSFinTsClient(callback: SimpleFinTsClientCallback(), webClient: UrlSessionWebClient())
private let formatter = DateFormatter()
func setEnterTanCallback(enterTanCallback: @escaping (TanChallenge) -> Void) {
self.fintsClient.callback = SimpleFinTsClientCallback( enterTan: { tanChallenge in
enterTanCallback(tanChallenge)
})
}
func retrieveTransactions(_ bankCode: String, _ customerId: String, _ pin: String, _ finTs3ServerAddress: String, _ callback: @escaping (AddAccountResponse) -> Void) {
self.fintsClient.addAccountAsync(parameter: AddAccountParameter(bankCode: bankCode, customerId: customerId, pin: pin, finTs3ServerAddress: finTs3ServerAddress), callback: callback)
}

View File

@ -1,11 +1,41 @@
import SwiftUI
import fints4k
@main
struct fints4k_iOSApp: App {
@StateObject var presenter = Presenter()
@State private var isShowingEnterTanDialog = false
// init with a default value
@State private var tanChallenge = TanChallenge(messageToShowToUser: "", challenge: "", tanMethod: TanMethod(displayName: "", securityFunction: .pinTan900, type: .entertan, hhdVersion: .hhd13, maxTanInputLength: 6, allowedTanFormat: .numeric, nameOfTanMediumRequired: false, decoupledParameters: nil), tanMediaIdentifier: nil)
var body: some Scene {
WindowGroup {
ContentView()
NavigationView {
VStack {
NavigationLink(destination: EnterTanDialog(tanChallenge), isActive: $isShowingEnterTanDialog) { EmptyView() }
ContentView()
.onAppear {
self.initApp()
}
.environmentObject(presenter)
}
.navigationBarHidden(true)
}
}
}
func initApp() {
self.presenter.setEnterTanCallback { tanChallenge in
self.tanChallenge = tanChallenge
self.isShowingEnterTanDialog = true
}
}
}

View File

@ -3,10 +3,10 @@ import fints4k
class UrlSessionWebClient : IWebClient {
func post(url: String, body: String, contentType: String, userAgent: String, callback: @escaping (WebClientResponse) -> Void) {
func post(url: String, body: String, contentType: String, userAgent: String, completionHandler: @escaping (WebClientResponse?, Error?) -> Void) {
let request = requestFor(url, "POST", body)
executeRequestAsync(request, callback)
executeRequestAsync(request) { response in completionHandler(response, nil) }
}
func getAsync(_ url: String, callback: @escaping (WebClientResponse) -> Void) {

View File

@ -0,0 +1,118 @@
import SwiftUI
import fints4k
struct EnterTanDialog: View {
@Environment(\.presentationMode) var presentation
private var tanChallenge: TanChallenge
private let imageTanChallenge: ImageTanChallenge?
private let messageToShowToUser: String
@State private var enteredTan = ""
private var senteredTanBinding: Binding<String> {
Binding<String>(
get: { self.enteredTan },
set: {
self.enteredTan = $0
})
}
@EnvironmentObject private var presenter: Presenter
init(_ tanChallenge: TanChallenge) {
self.tanChallenge = tanChallenge
self.imageTanChallenge = tanChallenge as? ImageTanChallenge
self.messageToShowToUser = tanChallenge.messageToShowToUser//.htmlToString // parse in init() calling this method in body { } crashes application
}
var body: some View {
Form {
imageTanChallenge.map { imageTanChallenge in
Image(uiImage: UIImage(data: imageTanChallenge.image.imageBytesAsNSData())!)
}
VStack {
HStack {
Text("TAN hint from your bank:")
Spacer()
}
HStack {
Text(messageToShowToUser)
.multilineTextAlignment(.leading)
.lineLimit(5) // hm, we doesn't it show more then three lines?
Spacer()
}
.padding(.top, 6)
}
.padding(.vertical, 2)
Section {
HStack(alignment: .center) {
Text("Enter TAN:")
Spacer()
TextField("Enter the TAN here", text: self.$enteredTan)
.keyboardType(tanChallenge.tanMethod.isNumericTan ? .numberPad : .default)
.autocapitalization(.none)
}
}
Section {
HStack {
Spacer()
Button(action: { self.enteringTanDone() },
label: { Text("OK") })
.disabled( !self.isRequiredDataEntered())
Spacer()
}
}
}
.navigationTitle("Enter TAN")
.navigationBarTitleDisplayMode(.inline)
}
private func isRequiredDataEntered() -> Bool {
return self.enteredTan.isEmpty == false
}
private func enteringTanDone() {
if self.isRequiredDataEntered() {
self.tanChallenge.userEnteredTan(enteredTan: self.enteredTan)
} else {
self.tanChallenge.userDidNotEnterTan()
}
// TODO: if a TAN has been entered, check result if user has made a mistake and has to re-enter TAN
dismissDialog()
}
private func dismissDialog() {
presentation.wrappedValue.dismiss()
}
}
//struct EnterTanDialog_Previews: PreviewProvider {
//
// static var previews: some View {
// EnterTanDialog()
// }
//
//}

View File

@ -1,5 +1,6 @@
package net.dankito.banking.fints
import kotlinx.coroutines.delay
import kotlinx.datetime.LocalDate
import net.dankito.banking.fints.messages.MessageBuilder
import net.dankito.banking.fints.messages.MessageBuilderResult
@ -17,7 +18,6 @@ import net.dankito.banking.fints.tan.FlickerCodeDecoder
import net.dankito.banking.fints.tan.TanImageDecoder
import net.dankito.banking.fints.util.TanMethodSelector
import net.dankito.utils.multiplatform.log.LoggerFactory
import net.dankito.utils.multiplatform.ObjectReference
import net.dankito.utils.multiplatform.extensions.millisSinceEpochAtEuropeBerlin
import net.dankito.utils.multiplatform.extensions.minusDays
import net.dankito.utils.multiplatform.extensions.todayAtEuropeBerlin
@ -376,17 +376,25 @@ open class FinTsJobExecutor(
protected open suspend fun handleEnteringTanRequired(context: JobContext, tanResponse: TanResponse, response: BankResponse): BankResponse {
val bank = context.bank // TODO: copy required data to TanChallenge
val tanChallenge = createTanChallenge(tanResponse, bank)
val userDidCancelEnteringTan = ObjectReference(false)
// on all platforms run on Dispatchers.Main, but on iOS skip this (or wrap in withContext(Dispatchers.IO) )
// val enteredTanResult = GlobalScope.async {
val tanChallenge = createTanChallenge(tanResponse, bank)
val enteredTanResult = context.callback.enterTan(bank, tanChallenge)
userDidCancelEnteringTan.value = true
context.callback.enterTan(tanChallenge)
while (tanChallenge.enterTanResult == null) {
delay(250)
mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context, tanChallenge, tanResponse)
// TODO: add a timeout of e.g. 30 min
}
val enteredTanResult = tanChallenge.enterTanResult!!
// }
return handleEnterTanResult(context, enteredTanResult, tanResponse, response)
// TODO:
// mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context, tanChallenge, tanResponse, userDidCancelEnteringTan)
}
protected open fun createTanChallenge(tanResponse: TanResponse, bank: BankData): TanChallenge {
@ -409,17 +417,15 @@ open class FinTsJobExecutor(
}
}
protected open fun mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge, tanResponse: TanResponse,
userDidCancelEnteringTan: ObjectReference<Boolean>
) {
protected open fun mayRetrieveAutomaticallyIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge, tanResponse: TanResponse) {
context.bank.selectedTanMethod.decoupledParameters?.let { decoupledTanMethodParameters ->
if (tanResponse.tanProcess == TanProcess.AppTan && decoupledTanMethodParameters.periodicStateRequestsAllowed) {
automaticallyRetrieveIfUserEnteredDecoupledTan(context, tanChallenge, userDidCancelEnteringTan)
automaticallyRetrieveIfUserEnteredDecoupledTan(context, tanChallenge)
}
}
}
protected open fun automaticallyRetrieveIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge, userDidCancelEnteringTan: ObjectReference<Boolean>) {
protected open fun automaticallyRetrieveIfUserEnteredDecoupledTan(context: JobContext, tanChallenge: TanChallenge) {
log.info("automaticallyRetrieveIfUserEnteredDecoupledTan() called for $tanChallenge")
}

View File

@ -18,7 +18,7 @@ interface FinTsClientCallback {
*/
suspend fun askUserForTanMethod(supportedTanMethods: List<TanMethod>, suggestedTanMethod: TanMethod?): TanMethod?
suspend fun enterTan(bank: BankData, tanChallenge: TanChallenge): EnterTanResult
suspend fun enterTan(tanChallenge: TanChallenge)
/**
* This method gets called for chipTan TAN generators when the bank asks the customer to synchronize her/his TAN generator.

View File

@ -10,8 +10,8 @@ open class NoOpFinTsClientCallback : FinTsClientCallback {
return suggestedTanMethod
}
override suspend fun enterTan(bank: BankData, tanChallenge: TanChallenge): EnterTanResult {
return EnterTanResult.userDidNotEnterTan()
override suspend fun enterTan(tanChallenge: TanChallenge) {
return tanChallenge.userDidNotEnterTan()
}
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanGeneratorTanMedium): EnterTanGeneratorAtcResult {

View File

@ -5,14 +5,14 @@ import net.dankito.banking.fints.model.*
open class SimpleFinTsClientCallback(
protected open val enterTan: ((bank: BankData, tanChallenge: TanChallenge) -> EnterTanResult)? = null,
protected open val enterTan: ((tanChallenge: TanChallenge) -> Unit)? = null,
protected open val enterTanGeneratorAtc: ((bank: BankData, tanMedium: TanGeneratorTanMedium) -> EnterTanGeneratorAtcResult)? = null,
protected open val askUserForTanMethod: ((supportedTanMethods: List<TanMethod>, suggestedTanMethod: TanMethod?) -> TanMethod?)? = null
) : FinTsClientCallback {
constructor() : this(null) // Swift does not support default parameter values -> create constructor overloads
constructor(enterTan: ((bank: BankData, tanChallenge: TanChallenge) -> EnterTanResult)?) : this(enterTan, null)
constructor(enterTan: ((tanChallenge: TanChallenge) -> Unit)?) : this(enterTan, null)
override suspend fun askUserForTanMethod(supportedTanMethods: List<TanMethod>, suggestedTanMethod: TanMethod?): TanMethod? {
@ -20,8 +20,8 @@ open class SimpleFinTsClientCallback(
return askUserForTanMethod?.invoke(supportedTanMethods, suggestedTanMethod) ?: suggestedTanMethod
}
override suspend fun enterTan(bank: BankData, tanChallenge: TanChallenge): EnterTanResult {
return enterTan?.invoke(bank, tanChallenge) ?: EnterTanResult.userDidNotEnterTan()
override suspend fun enterTan(tanChallenge: TanChallenge) {
enterTan?.invoke(tanChallenge) ?: run { tanChallenge.userDidNotEnterTan() }
}
override suspend fun enterTanGeneratorAtc(bank: BankData, tanMedium: TanGeneratorTanMedium): EnterTanGeneratorAtcResult {

View File

@ -4,33 +4,13 @@ import net.dankito.banking.fints.messages.datenelemente.implementierte.tan.TanMe
import net.dankito.banking.fints.response.client.FinTsClientResponse
open class EnterTanResult protected constructor(
open class EnterTanResult(
val enteredTan: String?,
val changeTanMethodTo: TanMethod? = null,
val changeTanMediumTo: TanMedium? = null,
val changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)? = null
) {
companion object {
fun userEnteredTan(enteredTan: String): EnterTanResult {
return EnterTanResult(enteredTan.replace(" ", ""))
}
fun userDidNotEnterTan(): EnterTanResult {
return EnterTanResult(null)
}
fun userAsksToChangeTanMethod(changeTanMethodTo: TanMethod): EnterTanResult {
return EnterTanResult(null, changeTanMethodTo)
}
fun userAsksToChangeTanMedium(changeTanMediumTo: TanMedium, changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)?): EnterTanResult {
return EnterTanResult(null, null, changeTanMediumTo, changeTanMediumResultCallback)
}
}
override fun toString(): String {
if (changeTanMethodTo != null) {
return "User asks to change TAN method to $changeTanMethodTo"

View File

@ -1,5 +1,8 @@
package net.dankito.banking.fints.model
import net.dankito.banking.fints.messages.datenelemente.implementierte.tan.TanMedium
import net.dankito.banking.fints.response.client.FinTsClientResponse
open class TanChallenge(
val messageToShowToUser: String,
@ -8,6 +11,30 @@ open class TanChallenge(
val tanMediaIdentifier: String?
) {
var enterTanResult: EnterTanResult? = null
private set
open val isEnteringTanDone: Boolean
get() = enterTanResult != null
fun userEnteredTan(enteredTan: String) {
this.enterTanResult = EnterTanResult(enteredTan.replace(" ", ""))
}
fun userDidNotEnterTan() {
this.enterTanResult = EnterTanResult(null)
}
fun userAsksToChangeTanMethod(changeTanMethodTo: TanMethod) {
this.enterTanResult = EnterTanResult(null, changeTanMethodTo)
}
fun userAsksToChangeTanMedium(changeTanMediumTo: TanMedium, changeTanMediumResultCallback: ((FinTsClientResponse) -> Unit)?) {
this.enterTanResult = EnterTanResult(null, null, changeTanMediumTo, changeTanMediumResultCallback)
}
override fun toString(): String {
return "$tanMethod (medium: $tanMediaIdentifier): $messageToShowToUser"
}

View File

@ -20,6 +20,10 @@ open class TanMethod(
open val isNumericTan: Boolean
get() = allowedTanFormat == AllowedTanFormat.Numeric
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TanMethod) return false

View File

@ -20,10 +20,6 @@ interface IWebClient {
}
suspend fun post(url: String, body: String): WebClientResponse { // some platforms don't support default parameters
return post(url, body)
}
suspend fun post(url: String, body: String, contentType: String = "application/octet-stream", userAgent: String = DefaultUserAgent): WebClientResponse
}

View File

@ -0,0 +1,11 @@
package net.dankito.banking.fints.extensions
import net.dankito.banking.fints.tan.TanImage
import net.dankito.utils.multiplatform.extensions.toNSData
import kotlinx.cinterop.*
import platform.Foundation.*
fun TanImage.imageBytesAsNSData(): NSData {
return imageBytes.toNSData()
}

View File

@ -0,0 +1,30 @@
package net.dankito.banking.fints
import kotlinx.coroutines.*
import net.dankito.banking.fints.callback.FinTsClientCallback
import net.dankito.banking.fints.model.AddAccountParameter
import net.dankito.banking.fints.response.client.AddAccountResponse
import net.dankito.banking.fints.webclient.IWebClient
open class iOSFinTsClient(
callback: FinTsClientCallback,
webClient: IWebClient
) {
protected open val fintsClient = FinTsClientDeprecated(callback, FinTsJobExecutor(RequestExecutor(webClient = webClient)))
open var callback: FinTsClientCallback
get() = fintsClient.callback
set(value) {
fintsClient.callback = value
}
open fun addAccountAsync(parameter: AddAccountParameter, callback: (AddAccountResponse) -> Unit) {
GlobalScope.launch(Dispatchers.Main) { // do not block UI thread as with runBlocking { } but stay on UI thread as passing mutable state between threads currently doesn't work in Kotlin/Native
callback(fintsClient.addAccountAsync(parameter))
}
}
}

View File

@ -4,6 +4,7 @@ import net.dankito.banking.fints.FinTsClientDeprecated
import net.dankito.banking.fints.callback.SimpleFinTsClientCallback
import net.dankito.banking.fints.model.AddAccountParameter
import net.dankito.banking.fints.model.RetrievedAccountData
import net.dankito.banking.fints.model.TanChallenge
import net.dankito.banking.fints.response.client.AddAccountResponse
import net.dankito.utils.multiplatform.extensions.*
import platform.posix.exit
@ -22,7 +23,7 @@ class Application {
fun retrieveAccountData(bankCode: String, customerId: String, pin: String, finTs3ServerAddress: String) {
runBlocking {
val client = FinTsClientDeprecated(SimpleFinTsClientCallback())
val client = FinTsClientDeprecated(SimpleFinTsClientCallback { tanChallenge -> enterTan(tanChallenge) })
val response = client.addAccountAsync(AddAccountParameter(bankCode, customerId, pin, finTs3ServerAddress))
@ -32,6 +33,23 @@ class Application {
}
}
private fun enterTan(tanChallenge: TanChallenge) {
println("A TAN is required:")
println(tanChallenge.messageToShowToUser)
println()
print("TAN: ")
val enteredTan = readLine()
if (enteredTan.isNullOrBlank()) {
tanChallenge.userDidNotEnterTan()
} else {
tanChallenge.userEnteredTan(enteredTan)
}
}
private fun displayRetrievedAccountData(response: AddAccountResponse) {
if (response.retrievedData.isEmpty()) {
println()

View File

@ -0,0 +1,12 @@
package net.dankito.utils.multiplatform.extensions
import kotlinx.cinterop.*
import platform.Foundation.*
fun ByteArray.toNSData(): NSData = NSMutableData().apply {
if (isEmpty()) return@apply
this@toNSData.usePinned {
appendBytes(it.addressOf(0), size.convert())
}
}