In this guide, we’ll explore how to generate the QCAT QR code by creating a custom TLV (Tag-Length-Value) structure in Kotlin. TLV encoding is commonly used in QR codes, smart cards, and other systems where data must be serialized into a compact, structured format. We'll also discuss encoding techniques, including converting date-times, durations, and ASCII values into hex format.
Overview of the Code
This Kotlin code focuses on generating a TLV structure that encodes various data fields such as a ticket ID, creator ID, creation timestamp, and a validity period into a hex-based format. Finally, it converts the TLV structure into a Base64 encoded string for easy transmission.
The structure involves the following major steps:
- Hex Conversion: Converting data types like date-time, durations, and ASCII strings to hexadecimal.
- TLV Encoding: Wrapping each piece of data with a tag, its length, and value.
- Serialization: Serializing the data and converting it to a byte array for encoding.
- Base64 Encoding: Final step where the TLV structure is Base64 encoded.
Also, to make the guide a bit shorter, we will be directly using their sample signature for building the TLV structure. To learn more on the signature creation you may refer the following guides:
TLV Sealed Class
This sealed class defines two types of TLV objects: SimpleTLV for basic key-value pairs and ConstructedTLV for nested TLV structures. Both types implement a serialize() function, returning a formatted hex string representing the TLV data.
sealed class TLV(val tag: String?, val length: String?) {
abstract fun serialize(): String
}
class SimpleTLV(tag: String?, length: String?, val value: String) : TLV(tag, length) {
override fun serialize(): String {
if (tag != null && length != null) {
return String.format("%s%s%s", tag, length, value)
} else {
return String.format("%s", value)
}
}
}
class ConstructedTLV(tag: String? = null, length: String? = null, val value: List<TLV>) : TLV(tag, length) {
override fun serialize(): String {
val nestedValues = value.joinToString("") { it.serialize() }
if (tag != null && length != null) {
return String.format("%s%s%s", tag, length, nestedValues)
} else {
return String.format("%s", nestedValues)
}
}
}
Key Functions
Duration To Hex String
This set of functions converts an ISO 8601 duration (e.g., "PT15M" for 15 minutes) into its equivalent hex representation. The conversion is done by parsing the duration, converting it to seconds, and then representing that value in hexadecimal.
fun String.toISO8601Duration(): Duration {
return Duration.parse(this)
}
fun Duration.toHexString(): String {
return this.seconds.toString(16).uppercase().padStart(4, '0')
}
Date to Hex String
This set of functions converts an ISO 8601 date-time string into Unix timestamp (seconds since epoch) and then converts it into hex format. The date-time is parsed into an OffsetDateTime, and its epoch second value is then converted into hex.
fun String.toISO8601DateTime(): OffsetDateTime {
return OffsetDateTime.parse(this, DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}
fun OffsetDateTime.toHexString(): String {
return this.toEpochSecond().toString(16).uppercase()
}
Integer to Hex String
The function converts an integer to an uppercase hexadecimal string, ensuring even length by prepending a "0" if needed.
fun Int.toHexString(): String {
return this.toString(16).uppercase().let { if (it.length % 2 != 0) "0$it" else it }
}
ASCII to Hex String
The function converts an ASCII string to its hexadecimal representation.
fun String.toHexString(): String {
return this.map { "${it.code.toString(16).uppercase()}" }.joinToString("")
}
Hex String to Byte Array
The function converts a hexadecimal string into a byte array. It checks that the input string has an even length (as each byte is represented by two hex characters) and ensures all characters are valid hexadecimal digits. Each pair of hex characters is combined into a byte, and the result is returned as a byte array.
fun String.hexStringToByteArray(): ByteArray {
require(this.length % 2 == 0) { "hex string must have an even length" }
val len = this.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
val firstChar = Character.digit(this[i], 16)
val secondChar = Character.digit(this[i + 1], 16)
require(firstChar != -1 && secondChar != -1) { "invalid hex character at index $i" }
data[i / 2] = ((firstChar shl 4) + secondChar).toByte()
i += 2
}
return data
}
Size/Length to Hex String
This set of functions handles encoding the length of a TLV entry, accounting for different lengths. If the length is under 128 bytes, a single-byte encoding is used. For longer lengths, multi-byte encoding is applied.
fun ByteArray.sizeToHexString(): String {
return encodeLengthToHexString(this.size)
}
fun Int.lengthToHexString(): String {
return encodeLengthToHexString(this)
}
private fun encodeLengthToHexString(length: Int): String {
val bytes = when {
length < 128 -> byteArrayOf(length.toByte()) // 1-byte length encoding
length <= 255 -> byteArrayOf(0x81.toByte(), length.toByte()) // 2-byte length encoding
else -> byteArrayOf(0x82.toByte(), (length shr 8).toByte(), length.toByte()) // 3-byte length encoding
}
return bytes.joinToString("") { String.format("%02X", it) }
}
To understand more about this implementation you may check out my guide below:
Create TLV Structure
This functionality builds the entire TLV structure by defining fields like ticket id, creator id, creation time, and validity period. Each field is converted to a TLV format, and the lengths of the values are encoded as per TLV rules.
object FieldTag {
const val PAYLOAD_FORMAT_INDICATOR = "85"
const val APPLICATION_TEMPLATE = "61"
const val ADF = "4F"
const val APPLICATION_SPECIFIC_TRANSPARENT_TEMPLATE = "63"
const val TICKET_ID = "C1"
const val CREATOR_ID = "C2"
const val CREATION_TIME = "C3"
const val VALIDITY_PERIOD = "C4"
const val SIGNATURE = "DE"
}
fun createTLVStructure(
ticketId: Int = 1250184,
creatorId: Int = 275,
creationTime: String = "2019-09-11T19:38:30+08:00",
validityPeriod: String = "PT15M",
): TLV {
val ticketIdHex = ticketId.toHexString()
val creatordIdHex = creatorId.toHexString()
val creationTimeHex = creationTime.toISO8601DateTime().toHexString()
val validityPeriodHex = validityPeriod.toISO8601Duration().toHexString()
val signatureHex = "023034021859527B7951E77EB6CB250149FFA2006B1A415297D13AA48A021840986DC05DB2235088DB4599389823A324842E73A635B3FD"
val adf = "QCAT01"
val adfHex = adf.toHexString()
val appSpecTransTemplate =
listOf(
SimpleTLV(
tag = FieldTag.TICKET_ID,
length = ticketIdHex.hexStringToByteArray().sizeToHexString(),
value = ticketIdHex
),
SimpleTLV(
tag = FieldTag.CREATOR_ID,
length = creatordIdHex.hexStringToByteArray().sizeToHexString(),
value = creatordIdHex
),
SimpleTLV(
tag = FieldTag.CREATION_TIME,
length = creationTimeHex.hexStringToByteArray().sizeToHexString(),
value = creationTimeHex
),
SimpleTLV(
tag = FieldTag.VALIDITY_PERIOD,
length = validityPeriodHex.hexStringToByteArray().sizeToHexString(),
value = validityPeriodHex
),
SimpleTLV(
tag = FieldTag.SIGNATURE,
length = signatureHex.hexStringToByteArray().sizeToHexString(),
value = signatureHex
),
)
val appTemplate =
listOf(
SimpleTLV(
tag = FieldTag.ADF,
length = adfHex.hexStringToByteArray().sizeToHexString(),
value = adfHex
),
ConstructedTLV(
tag = FieldTag.APPLICATION_SPECIFIC_TRANSPARENT_TEMPLATE,
length = appSpecTransTemplate.sumOf { it.serialize().length / 2 }.lengthToHexString(),
value = appSpecTransTemplate
)
)
val payloadFormatIndicator = "CPV01"
val payloadFormatIndicatorHex = payloadFormatIndicator.toHexString()
return ConstructedTLV(null, null, listOf(
SimpleTLV(
tag = FieldTag.PAYLOAD_FORMAT_INDICATOR,
length = payloadFormatIndicatorHex.hexStringToByteArray().sizeToHexString(),
value = payloadFormatIndicatorHex
),
ConstructedTLV(
tag = FieldTag.APPLICATION_TEMPLATE,
length = appTemplate.sumOf { it.serialize().length / 2 }.lengthToHexString(),
value = appTemplate
)
))
}
Full Code Example
Here’s a complete code demonstrating the use of all the key functions in which you can try using the Kotlin Playground:
import kotlin.test.*
import java.util.Base64
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.Duration
fun main() {
val tlv = createTLVStructure()
val serializedTLV = tlv.serialize()
val base64EncodedData = Base64.getEncoder().encodeToString(serializedTLV.hexStringToByteArray())
println("Serialized TLV: $serializedTLV")
println("QR Code Data: $base64EncodedData")
// Generate QR code using the chosen library
}
sealed class TLV(val tag: String?, val length: String?) {
abstract fun serialize(): String
}
class SimpleTLV(tag: String?, length: String?, val value: String) : TLV(tag, length) {
override fun serialize(): String {
if (tag != null && length != null) {
return String.format("%s%s%s", tag, length, value)
} else {
return String.format("%s", value)
}
}
}
class ConstructedTLV(tag: String? = null, length: String? = null, val value: List<TLV>) : TLV(tag, length) {
override fun serialize(): String {
val nestedValues = value.joinToString("") { it.serialize() }
if (tag != null && length != null) {
return String.format("%s%s%s", tag, length, nestedValues)
} else {
return String.format("%s", nestedValues)
}
}
}
fun String.toISO8601Duration(): Duration {
return Duration.parse(this)
}
fun Duration.toHexString(): String {
return this.seconds.toString(16).uppercase().padStart(4, '0')
}
fun String.toISO8601DateTime(): OffsetDateTime {
return OffsetDateTime.parse(this, DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}
fun OffsetDateTime.toHexString(): String {
return this.toEpochSecond().toString(16).uppercase()
}
fun Int.toHexString(): String {
return this.toString(16).uppercase().let { if (it.length % 2 != 0) "0$it" else it }
}
fun String.toHexString(): String {
return this.map { "${it.code.toString(16).uppercase()}" }.joinToString("")
}
fun String.hexStringToByteArray(): ByteArray {
require(this.length % 2 == 0) { "hex string must have an even length" }
val len = this.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
val firstChar = Character.digit(this[i], 16)
val secondChar = Character.digit(this[i + 1], 16)
require(firstChar != -1 && secondChar != -1) { "invalid hex character at index $i" }
data[i / 2] = ((firstChar shl 4) + secondChar).toByte()
i += 2
}
return data
}
fun ByteArray.sizeToHexString(): String {
return encodeLengthToHexString(this.size)
}
fun Int.lengthToHexString(): String {
return encodeLengthToHexString(this)
}
private fun encodeLengthToHexString(length: Int): String {
val bytes = when {
length < 128 -> byteArrayOf(length.toByte()) // 1-byte length encoding
length <= 255 -> byteArrayOf(0x81.toByte(), length.toByte()) // 2-byte length encoding
else -> byteArrayOf(0x82.toByte(), (length shr 8).toByte(), length.toByte()) // 3-byte length encoding
}
return bytes.joinToString("") { String.format("%02X", it) }
}
object FieldTag {
const val PAYLOAD_FORMAT_INDICATOR = "85"
const val APPLICATION_TEMPLATE = "61"
const val ADF = "4F"
const val APPLICATION_SPECIFIC_TRANSPARENT_TEMPLATE = "63"
const val TICKET_ID = "C1"
const val CREATOR_ID = "C2"
const val CREATION_TIME = "C3"
const val VALIDITY_PERIOD = "C4"
const val SIGNATURE = "DE"
}
fun createTLVStructure(
ticketId: Int = 1250184,
creatorId: Int = 275,
creationTime: String = "2019-09-11T19:38:30+08:00",
validityPeriod: String = "PT15M",
): TLV {
val ticketIdHex = ticketId.toHexString()
val creatordIdHex = creatorId.toHexString()
val creationTimeHex = creationTime.toISO8601DateTime().toHexString()
val validityPeriodHex = validityPeriod.toISO8601Duration().toHexString()
val signatureHex = "023034021859527B7951E77EB6CB250149FFA2006B1A415297D13AA48A021840986DC05DB2235088DB4599389823A324842E73A635B3FD"
val adf = "QCAT01"
val adfHex = adf.toHexString()
val appSpecTransTemplate =
listOf(
SimpleTLV(
tag = FieldTag.TICKET_ID,
length = ticketIdHex.hexStringToByteArray().sizeToHexString(),
value = ticketIdHex
),
SimpleTLV(
tag = FieldTag.CREATOR_ID,
length = creatordIdHex.hexStringToByteArray().sizeToHexString(),
value = creatordIdHex
),
SimpleTLV(
tag = FieldTag.CREATION_TIME,
length = creationTimeHex.hexStringToByteArray().sizeToHexString(),
value = creationTimeHex
),
SimpleTLV(
tag = FieldTag.VALIDITY_PERIOD,
length = validityPeriodHex.hexStringToByteArray().sizeToHexString(),
value = validityPeriodHex
),
SimpleTLV(
tag = FieldTag.SIGNATURE,
length = signatureHex.hexStringToByteArray().sizeToHexString(),
value = signatureHex
),
)
val appTemplate =
listOf(
SimpleTLV(
tag = FieldTag.ADF,
length = adfHex.hexStringToByteArray().sizeToHexString(),
value = adfHex
),
ConstructedTLV(
tag = FieldTag.APPLICATION_SPECIFIC_TRANSPARENT_TEMPLATE,
length = appSpecTransTemplate.sumOf { it.serialize().length / 2 }.lengthToHexString(),
value = appSpecTransTemplate
)
)
val payloadFormatIndicator = "CPV01"
val payloadFormatIndicatorHex = payloadFormatIndicator.toHexString()
return ConstructedTLV(null, null, listOf(
SimpleTLV(
tag = FieldTag.PAYLOAD_FORMAT_INDICATOR,
length = payloadFormatIndicatorHex.hexStringToByteArray().sizeToHexString(),
value = payloadFormatIndicatorHex
),
ConstructedTLV(
tag = FieldTag.APPLICATION_TEMPLATE,
length = appTemplate.sumOf { it.serialize().length / 2 }.lengthToHexString(),
value = appTemplate
)
))
}
Alternatively, you can opt not to use the .hexStringToByteArray().sizeToHexString() approach, see below full code example for reference:
import kotlin.test.*
import java.util.Base64
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.Duration
fun main() {
val tlv = createTLVStructure()
val serializedTLV = tlv.serialize()
val base64EncodedData = Base64.getEncoder().encodeToString(serializedTLV.hexStringToByteArray())
println("Serialized TLV: $serializedTLV")
println("QR Code Data: $base64EncodedData")
// Generate QR code using the chosen library
}
sealed class TLV(val tag: String?, val length: String?) {
abstract fun serialize(): String
}
class SimpleTLV(tag: String?, length: String?, val value: String) : TLV(tag, length) {
override fun serialize(): String {
if (tag != null && length != null) {
return String.format("%s%s%s", tag, length, value)
} else {
return String.format("%s", value)
}
}
}
class ConstructedTLV(tag: String? = null, length: String? = null, val value: List<TLV>) : TLV(tag, length) {
override fun serialize(): String {
val nestedValues = value.joinToString("") { it.serialize() }
if (tag != null && length != null) {
return String.format("%s%s%s", tag, length, nestedValues)
} else {
return String.format("%s", nestedValues)
}
}
}
fun String.toISO8601Duration(): Duration {
return Duration.parse(this)
}
fun Duration.toHexString(): String {
return this.seconds.toString(16).uppercase().padStart(4, '0')
}
fun String.toISO8601DateTime(): OffsetDateTime {
return OffsetDateTime.parse(this, DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}
fun OffsetDateTime.toHexString(): String {
return this.toEpochSecond().toString(16).uppercase()
}
fun Int.toHexString(): String {
return this.toString(16).uppercase().let { if (it.length % 2 != 0) "0$it" else it }
}
fun String.toHexString(): String {
return this.map { "${it.code.toString(16).uppercase()}" }.joinToString("")
}
fun String.hexStringToByteArray(): ByteArray {
require(this.length % 2 == 0) { "hex string must have an even length" }
val len = this.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
val firstChar = Character.digit(this[i], 16)
val secondChar = Character.digit(this[i + 1], 16)
require(firstChar != -1 && secondChar != -1) { "invalid hex character at index $i" }
data[i / 2] = ((firstChar shl 4) + secondChar).toByte()
i += 2
}
return data
}
fun Int.lengthToHexString(): String {
val bytes = when {
this < 128 -> byteArrayOf(this.toByte()) // 1-byte length encoding
this <= 255 -> byteArrayOf(0x81.toByte(), this.toByte()) // 2-byte length encoding
else -> byteArrayOf(0x82.toByte(), (this shr 8).toByte(), this.toByte()) // 3-byte length encoding
}
return bytes.joinToString("") { String.format("%02X", it) }
}
object FieldTag {
const val PAYLOAD_FORMAT_INDICATOR = "85"
const val APPLICATION_TEMPLATE = "61"
const val ADF = "4F"
const val APPLICATION_SPECIFIC_TRANSPARENT_TEMPLATE = "63"
const val TICKET_ID = "C1"
const val CREATOR_ID = "C2"
const val CREATION_TIME = "C3"
const val VALIDITY_PERIOD = "C4"
const val SIGNATURE = "DE"
}
fun createTLVStructure(
ticketId: Int = 1250184,
creatorId: Int = 275,
creationTime: String = "2019-09-11T19:38:30+08:00",
validityPeriod: String = "PT15M",
): TLV {
val ticketIdHex = ticketId.toHexString()
val creatordIdHex = creatorId.toHexString()
val creationTimeHex = creationTime.toISO8601DateTime().toHexString()
val validityPeriodHex = validityPeriod.toISO8601Duration().toHexString()
val signatureHex = "023034021859527B7951E77EB6CB250149FFA2006B1A415297D13AA48A021840986DC05DB2235088DB4599389823A324842E73A635B3FD"
val adf = "QCAT01"
val adfHex = adf.toHexString()
val appSpecTransTemplate =
listOf(
SimpleTLV(
tag = FieldTag.TICKET_ID,
length = (ticketIdHex.length / 2).lengthToHexString(),
value = ticketIdHex
),
SimpleTLV(
tag = FieldTag.CREATOR_ID,
length = (creatordIdHex.length / 2).lengthToHexString(),
value = creatordIdHex
),
SimpleTLV(
tag = FieldTag.CREATION_TIME,
length = (creationTimeHex.length / 2).lengthToHexString(),
value = creationTimeHex
),
SimpleTLV(
tag = FieldTag.VALIDITY_PERIOD,
length = (validityPeriodHex.length / 2).lengthToHexString(),
value = validityPeriodHex
),
SimpleTLV(
tag = FieldTag.SIGNATURE,
length = (signatureHex.length / 2).lengthToHexString(),
value = signatureHex
),
)
val appTemplate =
listOf(
SimpleTLV(
tag = FieldTag.ADF,
length = (adfHex.length / 2).lengthToHexString(),
value = adfHex
),
ConstructedTLV(
tag = FieldTag.APPLICATION_SPECIFIC_TRANSPARENT_TEMPLATE,
length = appSpecTransTemplate.sumOf { it.serialize().length / 2 }.lengthToHexString(),
value = appSpecTransTemplate
)
)
val payloadFormatIndicator = "CPV01"
val payloadFormatIndicatorHex = payloadFormatIndicator.toHexString()
return ConstructedTLV(null, null, listOf(
SimpleTLV(
tag = FieldTag.PAYLOAD_FORMAT_INDICATOR,
length = (payloadFormatIndicatorHex.length / 2).lengthToHexString(),
value = payloadFormatIndicatorHex
),
ConstructedTLV(
tag = FieldTag.APPLICATION_TEMPLATE,
length = appTemplate.sumOf { it.serialize().length / 2 }.lengthToHexString(),
value = appTemplate
)
))
}
The code might not be perfect, so feel free to make any adjustments you prefer. If you’d like to share your modifications with the community, please don’t hesitate to leave a comment below!
Conclusion
This Kotlin implementation demonstrates how to create a TLV structure, serialize it, and encode it in Base64. The code can be adapted for various use cases, such as generating QR codes, working with smart cards, or handling serialized data in payment systems.
By converting various data types (like dates, durations, and ASCII strings) into their hexadecimal representations, and combining them into a structured format, this TLV encoder can be used in many real-world applications requiring structured data serialization.
If you’d like to learn more or get involved in the QCAT ecosystem, feel free to check out my guides on QCAT specification below or check out AF Payments Inc.'s GitHub for the latest updates and contribution guidelines.
Don't forget to subscribe to my blog so you never miss out on my latest guides and content!
DISCLAIMER
The information provided in this article is for informational purposes only and does not constitute professional advice. While efforts have been made to ensure accuracy, AF Payments Inc. reserves the right to update or modify the QCAT standard and related materials at any time. Use of the QCAT standard is subject to the terms of its license agreement, and any implementation must adhere to AFPI's guidelines and licensing requirements. For the latest details and official documentation, please refer to AF Payments Inc.'s authorized channels.
Comments
Post a Comment