diff --git a/composeApp/src/androidMain/kotlin/net/codinux/banking/ui/dialogs/EnterTanDialogPreview.kt b/composeApp/src/androidMain/kotlin/net/codinux/banking/ui/dialogs/EnterTanDialogPreview.kt index 4b08614..d2ddc0a 100644 --- a/composeApp/src/androidMain/kotlin/net/codinux/banking/ui/dialogs/EnterTanDialogPreview.kt +++ b/composeApp/src/androidMain/kotlin/net/codinux/banking/ui/dialogs/EnterTanDialogPreview.kt @@ -63,7 +63,7 @@ fun EnterTanDialogPreview_WithMultipleTanMedia() { // shows that dialog is reall fun EnterTanDialogPreview_Flickercode() { val tanMethods = listOf(TanMethod("chipTAN Flickercode", TanMethodType.ChipTanFlickercode, "902")) val bank = BankViewInfo("12345678", "SupiDupiNutzer", "Abzockbank", BankingGroup.Postbank) - val tanChallenge = TanChallenge(TanChallengeType.Flickercode, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, bank = bank, flickerCode = FlickerCode("", "")) + val tanChallenge = TanChallenge(TanChallengeType.Flickercode, ActionRequiringTan.GetAccountInfo, "Geben Sie die TAN ein", tanMethods.first().identifier, tanMethods, bank = bank, flickerCode = FlickerCode("100880077104", "0604800771040F")) EnterTanDialog(TanChallengeReceived(tanChallenge) { }) { } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/tan/ChipTanFlickerCodeStripeView.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/tan/ChipTanFlickerCodeStripeView.kt new file mode 100644 index 0000000..99629e7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/tan/ChipTanFlickerCodeStripeView.kt @@ -0,0 +1,46 @@ +package net.codinux.banking.ui.composables.tan + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import net.codinux.banking.ui.service.tan.Bit + +@Composable +fun ChipTanFlickerCodeStripeView(stripe: Bit, width: Dp, showTanGeneratorMarker: Boolean = false) { + Column(Modifier.width(width).fillMaxHeight()) { + val markerHeight = width * 0.5f + val triangleSize = markerHeight.value * LocalDensity.current.density + + Column(Modifier.padding(bottom = 4.dp).width(width).height(markerHeight), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Bottom) { + if (showTanGeneratorMarker) { + val path = Path().apply { + // Line to the top-right corner of the triangle + lineTo(triangleSize, 0f) + + // Line to the bottom-center point of the triangle + lineTo(triangleSize / 2, triangleSize) + + // Close the path (line back to the starting point) + close() + } + + Canvas(modifier = Modifier.size(markerHeight)) { + drawPath(path, Color.White, 1f, Fill) + } + } + } + + Column(Modifier.fillMaxSize().background(if (stripe.isHigh) Color.White else Color.Black)) { + + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/tan/ChipTanFlickerCodeView.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/tan/ChipTanFlickerCodeView.kt new file mode 100644 index 0000000..6d9ffdc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/tan/ChipTanFlickerCodeView.kt @@ -0,0 +1,133 @@ +package net.codinux.banking.ui.composables.tan + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import net.codinux.banking.client.model.tan.FlickerCode +import net.codinux.banking.ui.service.tan.Bit +import net.codinux.banking.ui.service.tan.FlickerCodeAnimator +import net.codinux.banking.ui.service.tan.Step + + +private const val FrequencyStepSize = 2 + +private val StripesHeightStepSize = 7.dp +private val StripesWidthStepSize = 2.dp +private val SpaceBetweenStripesStepSize = 1.dp + + +@Composable +fun ChipTanFlickerCodeView(flickerCode: FlickerCode) { + + val animator = remember { FlickerCodeAnimator() } + + var stripesHeight by remember { mutableStateOf(240.dp) } + + var stripesWidth by remember { mutableStateOf(45.dp) } + + var spaceBetweenStripes by remember { mutableStateOf(15.dp) } + + var frequency by remember { mutableStateOf(FlickerCodeAnimator.DefaultFrequency) } + + var isPaused by remember { mutableStateOf(false) } + + var step by remember { mutableStateOf(Step(Bit.High, Bit.High, Bit.High, Bit.High, Bit.High)) } + + + fun setSize(width: Dp, height: Dp, spaceBetween: Dp) { + stripesWidth = width + stripesHeight = height + spaceBetweenStripes = spaceBetween + } + + fun decreaseSize() { + if (spaceBetweenStripes - SpaceBetweenStripesStepSize > 0.dp) { + setSize( + stripesWidth - StripesWidthStepSize, + stripesHeight - StripesHeightStepSize, + spaceBetweenStripes - SpaceBetweenStripesStepSize + ) + } + } + + fun increaseSize() { // set also an upper limit to size? + setSize( + stripesWidth + StripesWidthStepSize, + stripesHeight + StripesHeightStepSize, + spaceBetweenStripes + SpaceBetweenStripesStepSize + ) + } + + fun toggleIsPaused() { + isPaused = !isPaused + + if (isPaused) { + animator.pause() + } else { + animator.resume() + } + } + + + Column(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + ImageSizeControls(true, true, { decreaseSize() }, { increaseSize() }) + + Spacer(Modifier.width(16.dp)) + + IconButton({ toggleIsPaused() }) { + if (isPaused) { + Icon(Icons.Filled.PlayArrow, "FlickerCode Animation wieder starten") + } else { + Icon(Icons.Filled.Pause, "FlickerCode Animation pausieren") + } + } + } + + Row(Modifier.background(Color.Black).padding(vertical = 20.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.fillMaxWidth().height(stripesHeight).background(Color.Black), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + ChipTanFlickerCodeStripeView(step.bit1, stripesWidth, true) + + Spacer(Modifier.width(spaceBetweenStripes)) + + ChipTanFlickerCodeStripeView(step.bit2, stripesWidth) + + Spacer(Modifier.width(spaceBetweenStripes)) + + ChipTanFlickerCodeStripeView(step.bit3, stripesWidth) + + Spacer(Modifier.width(spaceBetweenStripes)) + + ChipTanFlickerCodeStripeView(step.bit4, stripesWidth) + + Spacer(Modifier.width(spaceBetweenStripes)) + + ChipTanFlickerCodeStripeView(step.bit5, stripesWidth, true) + } + } + } + + + DisposableEffect(animator) { + animator.setFrequency(frequency) + + animator.animateFlickerCode(flickerCode) { + step = it + } + + onDispose { + animator.stop() + } + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/tan/ImageSizeControls.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/tan/ImageSizeControls.kt new file mode 100644 index 0000000..da345bc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/composables/tan/ImageSizeControls.kt @@ -0,0 +1,32 @@ +package net.codinux.banking.ui.composables.tan + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ZoomIn +import androidx.compose.material.icons.filled.ZoomOut +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun ImageSizeControls(decreaseEnabled: Boolean, increaseEnabled: Boolean, onDecreaseImageSize: () -> Unit, onIncreaseImageSize: () -> Unit) { + + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Größe") + + Spacer(Modifier.width(6.dp)) + + TextButton({ onDecreaseImageSize() }, enabled = decreaseEnabled, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) { + Icon(Icons.Filled.ZoomOut, contentDescription = "Bild verkleiner", Modifier.size(28.dp)) + } + + Spacer(Modifier.width(6.dp)) + + TextButton({ onIncreaseImageSize() }, enabled = increaseEnabled, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) { + Icon(Icons.Filled.ZoomIn, contentDescription = "Bild vergrößern", Modifier.size(28.dp)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/EnterTanDialog.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/EnterTanDialog.kt index 0cf7ea7..13450b7 100644 --- a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/EnterTanDialog.kt +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/dialogs/EnterTanDialog.kt @@ -4,15 +4,11 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ZoomIn -import androidx.compose.material.icons.filled.ZoomOut import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -20,6 +16,8 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import net.codinux.banking.client.model.tan.* import net.codinux.banking.ui.composables.BankIcon +import net.codinux.banking.ui.composables.tan.ChipTanFlickerCodeView +import net.codinux.banking.ui.composables.tan.ImageSizeControls import net.codinux.banking.ui.config.Colors import net.codinux.banking.ui.config.DI import net.codinux.banking.ui.config.Internationalization @@ -29,7 +27,6 @@ import net.codinux.banking.ui.forms.Select import net.codinux.banking.ui.model.TanChallengeReceived import net.codinux.banking.ui.model.error.ErroneousAction import net.codinux.banking.ui.service.createImageBitmap -import net.codinux.log.Log import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -114,19 +111,9 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () -> "TAN Verfahren", challenge.availableTanMethods.sortedBy { it.identifier }, challenge.selectedTanMethod, - { tanMethod -> - if (tanMethod.type != TanMethodType.ChipTanFlickercode) { - tanChallengeReceived.callback(EnterTanResult(null, tanMethod)) - } - }, + { tanMethod -> tanChallengeReceived.callback(EnterTanResult(null, tanMethod)) }, { it.displayName } - ) { tanMethod -> - if (tanMethod.type == TanMethodType.ChipTanFlickercode) { - Text(tanMethod.displayName + " (noch nicht implementiert)", color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled)) - } else { - Text(tanMethod.displayName) - } - } + ) { tanMethod -> Text(tanMethod.displayName) } } if (challenge.availableTanMedia.isNotEmpty()) { @@ -148,9 +135,8 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () -> if (challenge.tanImage != null || challenge.flickerCode != null) { Column(Modifier.fillMaxWidth().padding(top = 6.dp)) { - if (challenge.flickerCode != null) { - Text("Es tut uns Leid, für die TAN müsste ein Flickercode angezeigt werden, was wir noch nicht implementiert haben.") - Text("Bitte wählen Sie ein anderes TAN Verfahren, z. B. chipTAN-QrCode oder manuelle TAN Eingabe wie chipTAN manuell.", Modifier.padding(top = 6.dp)) + challenge.flickerCode?.let { flickerCode -> + ChipTanFlickerCodeView(flickerCode) } challenge.tanImage?.let { tanImage -> @@ -158,15 +144,7 @@ fun EnterTanDialog(tanChallengeReceived: TanChallengeReceived, onDismiss: () -> val imageBytes = Base64.decode(tanImage.imageBytesBase64) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - Text("Größe") - Spacer(Modifier.width(6.dp)) - TextButton({ tanImageHeight -= 25}, enabled = tanImageHeight > minTanImageHeight, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) { - Icon(Icons.Filled.ZoomOut, contentDescription = "Bild mit enkodierter TAN verkleiner", Modifier.size(28.dp)) - } - Spacer(Modifier.width(6.dp)) - TextButton({ tanImageHeight += 25}, enabled = tanImageHeight < maxTanImageHeight, modifier = Modifier.width(48.dp), colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)) { - Icon(Icons.Filled.ZoomIn, contentDescription = "Bild mit enkodierter TAN vergrößern", Modifier.size(28.dp)) - } + ImageSizeControls(tanImageHeight > minTanImageHeight, tanImageHeight < maxTanImageHeight, { tanImageHeight -= 25 }) { tanImageHeight += 25 } } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/Bit.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/Bit.kt new file mode 100644 index 0000000..292f7c9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/Bit.kt @@ -0,0 +1,28 @@ +package net.codinux.banking.ui.service.tan + +enum class Bit(val value: Int) { + + Low(0), + + High(1); + + + val isHigh: Boolean + get() = this == High + + val isLow: Boolean = !!! this.isHigh + + fun invert(): Bit { + if (this == High) { + return Low + } + + return High + } + + + override fun toString(): String { + return "$value" + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/FlickerCodeAnimator.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/FlickerCodeAnimator.kt new file mode 100644 index 0000000..ed6c732 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/FlickerCodeAnimator.kt @@ -0,0 +1,85 @@ +package net.codinux.banking.ui.service.tan + +import kotlinx.coroutines.* +import net.codinux.banking.client.model.tan.FlickerCode +import net.codinux.log.logger +import kotlin.concurrent.Volatile + +class FlickerCodeAnimator { + + companion object { + const val MinFrequency = 2 + const val MaxFrequency = 40 + const val DefaultFrequency = 30 + } + + + @Volatile + private var currentFrequency: Int = DefaultFrequency + + @Volatile + private var isPaused = false + + private var animationJob: Job? = null + + private val log by logger() + + + + fun animateFlickerCode(flickerCode: FlickerCode, showStep: (Step) -> Unit) { // TODO: find better coroutine scope + stop() // stop may still running previous animation + + animationJob = GlobalScope.launch(Dispatchers.Default) { + val steps = FlickerCodeStepsCalculator().calculateSteps(flickerCode.parsedDataSet) + + calculateAnimation(steps, showStep) + } + } + + private suspend fun calculateAnimation(steps: List, showStep: (Step) -> Unit) { + var currentStepIndex = 0 + + while (true) { + if (isPaused == false) { + val nextStep = steps[currentStepIndex] + + withContext(Dispatchers.Main) { + showStep(nextStep) + } + + currentStepIndex++ + if (currentStepIndex >= steps.size) { + currentStepIndex = 0 // all steps shown, start again from beginning + } + } + + delay(1000L / currentFrequency) + } + } + + fun pause() { + this.isPaused = true + } + + fun resume() { + this.isPaused = false + } + + fun stop() { + try { + animationJob?.cancel() + + animationJob = null + } catch (e: Exception) { + log.warn(e) { "Could not stop animation job" } + } + } + + + fun setFrequency(frequency: Int) { + if (frequency in MinFrequency..MaxFrequency) { + currentFrequency = frequency + } + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/FlickerCodeStepsCalculator.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/FlickerCodeStepsCalculator.kt new file mode 100644 index 0000000..1da730d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/FlickerCodeStepsCalculator.kt @@ -0,0 +1,76 @@ +package net.codinux.banking.ui.service.tan + +open class FlickerCodeStepsCalculator { + + companion object { + + val bits = mutableMapOf() + + init { + /* bitfield: clock, bits 2^1, 2^2, 2^3, 2^4 */ + bits['0'] = Step(Bit.Low, Bit.Low, Bit.Low, Bit.Low, Bit.Low) + bits['1'] = Step(Bit.Low, Bit.High, Bit.Low, Bit.Low, Bit.Low) + bits['2'] = Step(Bit.Low, Bit.Low, Bit.High, Bit.Low, Bit.Low) + bits['3'] = Step(Bit.Low, Bit.High, Bit.High, Bit.Low, Bit.Low) + bits['4'] = Step(Bit.Low, Bit.Low, Bit.Low, Bit.High, Bit.Low) + bits['5'] = Step(Bit.Low, Bit.High, Bit.Low, Bit.High, Bit.Low) + bits['6'] = Step(Bit.Low, Bit.Low, Bit.High, Bit.High, Bit.Low) + bits['7'] = Step(Bit.Low, Bit.High, Bit.High, Bit.High, Bit.Low) + bits['8'] = Step(Bit.Low, Bit.Low, Bit.Low, Bit.Low, Bit.High) + bits['9'] = Step(Bit.Low, Bit.High, Bit.Low, Bit.Low, Bit.High) + bits['A'] = Step(Bit.Low, Bit.Low, Bit.High, Bit.Low, Bit.High) + bits['B'] = Step(Bit.Low, Bit.High, Bit.High, Bit.Low, Bit.High) + bits['C'] = Step(Bit.Low, Bit.Low, Bit.Low, Bit.High, Bit.High) + bits['D'] = Step(Bit.Low, Bit.High, Bit.Low, Bit.High, Bit.High) + bits['E'] = Step(Bit.Low, Bit.Low, Bit.High, Bit.High, Bit.High) + bits['F'] = Step(Bit.Low, Bit.High, Bit.High, Bit.High, Bit.High) + } + + } + + + open fun calculateSteps(flickerCode: String): List { + + val halfbyteid = ObjectHolder(0) + val clock = ObjectHolder(Bit.High) + val bitarray = mutableListOf() + + + /* prepend synchronization identifier */ + var code = "0FFF" + flickerCode + if (code.length % 2 != 0) { + code += "F" + } + + for (i in 0 until code.length step 2) { + bits[code[i + 1]]?.let { bitarray.add(it) } + bits[code[i]]?.let { bitarray.add(it) } + } + + val steps = mutableListOf() + + do { + steps.add(calculateStep(halfbyteid, clock, bitarray)) + } while (halfbyteid.value > 0 || clock.value == Bit.Low) + + return steps + } + + protected open fun calculateStep(halfbyteid: ObjectHolder, clock: ObjectHolder, bitarray: List): Step { + val step = Step(clock.value, bitarray[halfbyteid.value]) + + clock.value = clock.value.invert() + + if (clock.value == Bit.High) { + halfbyteid.value++ + + if (halfbyteid.value >= bitarray.size) { + halfbyteid.value = 0 + } + + } + + return step + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/ObjectHolder.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/ObjectHolder.kt new file mode 100644 index 0000000..ba892b7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/ObjectHolder.kt @@ -0,0 +1,3 @@ +package net.codinux.banking.ui.service.tan + +data class ObjectHolder(var value: T) diff --git a/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/Step.kt b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/Step.kt new file mode 100644 index 0000000..91869c7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/net/codinux/banking/ui/service/tan/Step.kt @@ -0,0 +1,16 @@ +package net.codinux.banking.ui.service.tan + +open class Step( + val bit1: Bit, + val bit2: Bit, + val bit3: Bit, + val bit4: Bit, + val bit5: Bit +) { + + constructor(clockBit: Bit, step: Step) : this(clockBit, step.bit2, step.bit3, step.bit4, step.bit5) + + + override fun toString() = "${bit1}${bit2}${bit3}${bit4}$bit5" + +} \ No newline at end of file