Implemented displaying FlickerCodes

This commit is contained in:
dankito 2024-09-26 12:13:25 +02:00
parent 4cdc573364
commit ebbdd56418
10 changed files with 427 additions and 30 deletions

View File

@ -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) { }) { }
}

View File

@ -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)) {
}
}
}

View File

@ -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()
}
}
}

View File

@ -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))
}
}
}

View File

@ -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) {

View File

@ -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"
}
}

View File

@ -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<Step>, 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
}
}
}

View File

@ -0,0 +1,76 @@
package net.codinux.banking.ui.service.tan
open class FlickerCodeStepsCalculator {
companion object {
val bits = mutableMapOf<Char, Step>()
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<Step> {
val halfbyteid = ObjectHolder(0)
val clock = ObjectHolder(Bit.High)
val bitarray = mutableListOf<Step>()
/* 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<Step>()
do {
steps.add(calculateStep(halfbyteid, clock, bitarray))
} while (halfbyteid.value > 0 || clock.value == Bit.Low)
return steps
}
protected open fun calculateStep(halfbyteid: ObjectHolder<Int>, clock: ObjectHolder<Bit>, bitarray: List<Step>): 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
}
}

View File

@ -0,0 +1,3 @@
package net.codinux.banking.ui.service.tan
data class ObjectHolder<T>(var value: T)

View File

@ -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"
}