Skip to content

Commit

Permalink
Use Base64 for array encoding in SerializableContainer
Browse files Browse the repository at this point in the history
  • Loading branch information
arkivanov committed Dec 11, 2023
1 parent 529e572 commit 24dcd65
Show file tree
Hide file tree
Showing 10 changed files with 336 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package com.arkivanov.essenty.statekeeper

import com.arkivanov.essenty.statekeeper.base64.ByteArrayAsBase64StringSerializer
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.builtins.ByteArraySerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder

Expand Down Expand Up @@ -61,27 +59,24 @@ class SerializableContainer private constructor(
)

internal object Serializer : KSerializer<SerializableContainer> {
private val byteArraySerializer = ByteArraySerializer()
private val serializer = Surrogate.serializer()

override val descriptor: SerialDescriptor =
buildClassSerialDescriptor(serialName = "SerializableContainer") {
element<Boolean>(elementName = "exists")
element(elementName = "data", descriptor = byteArraySerializer.descriptor, isOptional = true)
}
override val descriptor: SerialDescriptor = serializer.descriptor

override fun serialize(encoder: Encoder, value: SerializableContainer) {
val data = value.holder?.serialize()
encoder.encodeBoolean(data != null)
if (data != null) {
encoder.encodeSerializableValue(serializer = byteArraySerializer, value = data)
}
serializer.serialize(encoder, Surrogate(data = value.holder?.serialize() ?: value.data))
}

private fun <T : Any> Holder<T>.serialize(): ByteArray? =
value?.serialize(strategy)

override fun deserialize(decoder: Decoder): SerializableContainer =
SerializableContainer(data = decoder.takeIf(Decoder::decodeBoolean)?.decodeSerializableValue(byteArraySerializer))
SerializableContainer(data = serializer.deserialize(decoder).data)

@Serializable
private class Surrogate(
@Serializable(ByteArrayAsBase64StringSerializer::class) val data: ByteArray? = null,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.arkivanov.essenty.statekeeper.base64

import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder

/**
* Serializer that encodes and decodes [ByteArray] using [Base64](https://en.wikipedia.org/wiki/Base64) encodings.
* This is usually makes sense with text formats like JSON.
*/
internal object ByteArrayAsBase64StringSerializer : KSerializer<ByteArray> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
"kotlinx.serialization.ByteArrayAsBase64StringSerializer",
PrimitiveKind.STRING
)

override fun deserialize(decoder: Decoder): ByteArray {
return decode(decoder.decodeString())
}

override fun serialize(encoder: Encoder, value: ByteArray) {
encoder.encodeString(encode(value))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.arkivanov.essenty.statekeeper.base64

internal fun decode(encoded: String): ByteArray {
if (encoded.isBlank()) return ByteArray(0)
val result = ByteArray(encoded.length)
var resultSize = 0

val backDictionary = backDictionary
var buffer = 0
var buffered = 0
var index = 0

while (index < encoded.length) {
val ch = encoded[index++]
if (ch <= ' ') continue
if (ch == '=') {
index--
break
}
val value = backDictionary.getOrElse(ch.code) { -1 }
if (value == -1) error("Unexpected character $ch (${ch.code})) in $encoded")

buffer = buffer shl 6 or value
buffered++

if (buffered == 4) {
result[resultSize] = (buffer shr 16).toByte()
result[resultSize + 1] = (buffer shr 8 and 0xff).toByte()
result[resultSize + 2] = (buffer and 0xff).toByte()
resultSize += 3
buffered = 0
buffer = 0
}
}

var padding = 0
while (index < encoded.length) {
val ch = encoded[index++]
if (ch <= ' ') continue
check(ch == '=')
padding++
buffer = buffer shl 6
buffered++
}

if (buffered == 4) {
result[resultSize] = (buffer shr 16).toByte()
result[resultSize + 1] = (buffer shr 8 and 0xff).toByte()
result[resultSize + 2] = (buffer and 0xff).toByte()
resultSize += 3

resultSize -= padding
buffered = 0
}

check(buffered == 0) {
"buffered: $buffered"
}

return when {
resultSize < result.size -> result.copyOf(resultSize)
else -> result
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.arkivanov.essenty.statekeeper.base64

internal val dictionary: CharArray = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray()

internal val backDictionary: IntArray = IntArray(0x80) { code ->
dictionary.indexOf(code.toChar())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.arkivanov.essenty.statekeeper.base64

internal fun encode(array: ByteArray): String = buildString(capacity = (array.size / 3) * 4 + 1) {
var index = 0

while (index < array.size) {
if (index + 3 > array.size) break

val buffer = array[index].toInt() and 0xff shl 16 or
(array[index + 1].toInt() and 0xff shl 8) or
(array[index + 2].toInt() and 0xff shl 0)

append(dictionary[buffer shr 18])
append(dictionary[buffer shr 12 and 0x3f])
append(dictionary[buffer shr 6 and 0x3f])
append(dictionary[buffer and 0x3f])

index += 3
}

if (index < array.size) {
var buffer = 0
while (index < array.size) {
buffer = buffer shl 8 or (array[index].toInt() and 0xff)
index++
}
val padding = 3 - (index % 3)
buffer = buffer shl (padding * 8)

append(dictionary[buffer shr 18])
append(dictionary[buffer shr 12 and 0x3f])

val a = dictionary[buffer shr 6 and 0x3f]
val b = dictionary[buffer and 0x3f]

when (padding) {
0 -> {
append(a)
append(b)
}
1 -> {
append(a)
append('=')
}
2 -> {
append("==")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The content of this package was copied from https://github.com/cy6erGn0m/kotlinx.serialization/tree/cy/base64/formats/base64/commonMain/src/kotlinx.serialization.base64/impl.

Waiting for https://github.com/Kotlin/kotlinx.serialization/issues/1633.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.arkivanov.essenty.statekeeper

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

@Suppress("TestFunctionName")
class SerializableContainerTest {

@Test
fun GIVEN_value_not_set_WHEN_consume_THEN_returns_null() {
val container = SerializableContainer()

val data = container.consume(SerializableData.serializer())

assertNull(data)
}

@Test
fun GIVEN_value_set_WHEN_consume_THEN_returns_value() {
val data = SerializableData()
val container = SerializableContainer(value = data, strategy = SerializableData.serializer())

val newData = container.consume(SerializableData.serializer())

assertEquals(data, newData)
}

@Test
fun GIVEN_value_set_and_consumed_WHEN_consume_second_time_THEN_returns_null() {
val container = SerializableContainer(value = SerializableData(), strategy = SerializableData.serializer())
container.consume(SerializableData.serializer())

val newData = container.consume(SerializableData.serializer())

assertNull(newData)
}

@Test
fun serializes_and_deserializes_data() {
val data = SerializableData()
val container = SerializableContainer(value = data, strategy = SerializableData.serializer())
val newContainer = container.serializeAndDeserialize()
val newData = newContainer.consume(strategy = SerializableData.serializer())

assertEquals(data, newData)
}

@Test
fun serializes_and_deserializes_data_twice() {
val data = SerializableData()
val container = SerializableContainer(value = data, strategy = SerializableData.serializer())
val newContainer = container.serializeAndDeserialize().serializeAndDeserialize()
val newData = newContainer.consume(strategy = SerializableData.serializer())

assertEquals(data, newData)
}

@Test
fun serializes_and_deserializes_null() {
val container = SerializableContainer()
val newContainer = container.serializeAndDeserialize()
val newData = newContainer.consume(strategy = SerializableData.serializer())

assertNull(newData)
}

@Test
fun serializes_and_deserializes_null_twice() {
val container = SerializableContainer()
val newContainer = container.serializeAndDeserialize().serializeAndDeserialize()
val newData = newContainer.consume(strategy = SerializableData.serializer())

assertNull(newData)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.arkivanov.essenty.statekeeper.base64

import kotlin.test.Test
import kotlin.test.assertEquals

class Base64ImplTest {

@Test
fun encodeSmokeTests() {
testEncode("123", "MTIz")
testEncode("abcdef", "YWJjZGVm")

testEncode("1", "MQ==")
testEncode("2", "Mg==")
testEncode("12", "MTI=")

testEncode("abcd", "YWJjZA==")
testEncode("abcde", "YWJjZGU=")

// RFC's testcases
testEncode("", "")
testEncode("f", "Zg==")
testEncode("fo", "Zm8=")
testEncode("foo", "Zm9v")
testEncode("foob", "Zm9vYg==")
testEncode("fooba", "Zm9vYmE=")
testEncode("foobar", "Zm9vYmFy")
}

@Test
fun decodeSmokeTests() {
testDecode("123", "MTIz")
testDecode("abcdef", "YWJjZGVm")

testDecode("1", "MQ==")
testDecode("2", "Mg==")
testDecode("12", "MTI=")

testDecode("abcd", "YWJjZA==")
testDecode("abcde", "YWJjZGU=")

// RFC
// RFC's testcases
testDecode("", "")
testDecode("f", "Zg==")
testDecode("fo", "Zm8=")
testDecode("foo", "Zm9v")
testDecode("foob", "Zm9vYg==")
testDecode("fooba", "Zm9vYmE=")
testDecode("foobar", "Zm9vYmFy")
}

private fun testEncode(input: String, expected: String) {
val result = encode(input.encodeToByteArray())
assertEquals(expected, result)
}

private fun testDecode(expected: String, encoded: String) {
val result = decode(encoded).decodeToString()
assertEquals(expected, result)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.arkivanov.essenty.statekeeper.base64

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals

class ByteArraySerializerTest {
@Serializable
class E(
@Serializable(with = ByteArrayAsBase64StringSerializer::class)
val bytes: ByteArray
)

@Test
fun serialize() {
val element = Json.encodeToJsonElement(E(byteArrayOf(0x31, 0x32, 0x33))).jsonObject
val base64 = element["bytes"]?.jsonPrimitive?.content!!

assertEquals("MTIz", base64)
}

@Test
fun deserialize() {
val json = "{\"bytes\": \"MTIz\"}"
val decoded = Json.decodeFromString<E>(json).bytes

assertContentEquals(byteArrayOf(0x31, 0x32, 0x33), decoded)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The content of this package was copied from https://github.com/cy6erGn0m/kotlinx.serialization/tree/cy/base64/formats/base64/commonTest/src/kotlinx/serialization/base64.

Waiting for https://github.com/Kotlin/kotlinx.serialization/issues/1633.

0 comments on commit 24dcd65

Please sign in to comment.