diff --git a/CHANGELOG.MD b/CHANGELOG.MD index aecba510..28e079bf 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,5 +1,20 @@ # Changelog +## v2.10.0 + +### Changes + +#### Binary Scale Format (BinaryScale) + +* Added comprehensive support for binary SCALE codec encoding and decoding +* `BinaryScale` provides direct conversion between Kotlin types and SCALE-encoded byte arrays +* Supports all SCALE types: primitives, compact integers, strings, byte arrays, lists, optionals, classes, objects, value classes, enums, and sealed classes +* Introduced binary-specific annotations: + * `@EnumIndex` - Specify custom indices for enum entries and sealed class variants + * `@FixedLength` - Encode collections without length prefix for fixed-size arrays and lists + * Wrapper classes `WithLength20`, `WithLength32`, `WithLength64` for generic fixed-length types +* Added complete test coverage with symmetrical encode/decode test suites + ## v2.9.3 ### Changes diff --git a/build.gradle b/build.gradle index 328e5ce8..0553e8a1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { // App version - versionName = '2.9.3' + versionName = '2.10.0' versionCode = 1 // SDK and tools @@ -9,7 +9,7 @@ buildscript { minVersion = 21 targetVersion = 30 - kotlinVersion = '2.1.21' + kotlinVersion = '2.2.21' supportVersion = '1.0.0' constraintVersion = '1.1.3' diff --git a/koltinx-serialization-scale/README.md b/koltinx-serialization-scale/README.md index 1eeb6776..c155a84e 100644 --- a/koltinx-serialization-scale/README.md +++ b/koltinx-serialization-scale/README.md @@ -1,7 +1,54 @@ # SCALE format support for kotlinx-serialization -This module providers support for converting between regular kotlin types and dynamic structures used by -the core substrate-sdk-android module +This module provides support for SCALE codec serialization in two formats: +- **Dynamic Format (Scale)**: Converts between Kotlin types and dynamic structures used by the core substrate-sdk-android module +- **Binary Format (BinaryScale)**: Direct binary encoding/decoding following the SCALE codec specification + +## Table of Contents + +- [Get started](#get-started) + - [Installation](#installation) +- [Dynamic Format (Scale)](#dynamic-format-scale) + - [Usage](#usage) + - [Scale Types](#scale-types) + - [Primitives](#primitives) + - [Strings](#strings) + - [Byte Arrays](#byte-arrays) + - [Booleans](#booleans) + - [Regular enums](#regular-enums) + - [Classes](#classes) + - [Objects](#objects) + - [Value classes](#value-classes) + - [Sealed classes](#sealed-classes) + - [Annotations](#annotations) + - [@SerialName](#serialname-annotation) + - [@SerializedFallback](#serializedfallback-annotation) + - [@TransientStruct](#transientstruct-annotation) + - [@AsTuple](#astuple-annotation) +- [Binary Format (BinaryScale)](#binary-format-binaryscale) + - [Usage](#usage-1) + - [Binary Types](#binary-types) + - [Primitives](#primitives-1) + - [Booleans](#booleans-1) + - [Numbers](#numbers) + - [Compact Integers](#compact-integers) + - [Strings](#strings-1) + - [Byte Arrays](#byte-arrays-1) + - [Variable Length](#variable-length) + - [Fixed Length](#fixed-length) + - [Lists](#lists) + - [Variable Length](#variable-length-1) + - [Fixed Length](#fixed-length-1) + - [Optional Types](#optional-types) + - [Optional Booleans](#optional-booleans) + - [Classes](#classes-1) + - [Objects](#objects-1) + - [Value Classes](#value-classes-1) + - [Enums](#enums) + - [Sealed Classes (Discriminated Unions)](#sealed-classes-discriminated-unions) + - [Annotations](#annotations-1) + - [@EnumIndex](#enumindex) + - [@FixedLength](#fixedlength) ## Get started @@ -31,6 +78,12 @@ dependencies { } ``` +--- + +## Dynamic Format (Scale) + +The `Scale` format converts Kotlin types to/from dynamic structures compatible with substrate-sdk-android. + ### Usage 1. Declare a new type that you want to convert to/from @@ -43,7 +96,6 @@ class Test(val a: Boolean) 2. Use `Scale` object to encode and decode instances of `Test` ```kotlin - val decoded = Test(a=true) val encoded = Struct.Instance(mapOf("a" to true)) @@ -51,9 +103,7 @@ assert(encoded == Scale.encode(decoded)) assert(decoded == Scale.decode(encoded)) ``` -## Features - -### Scale types +### Scale Types #### Primitives @@ -271,4 +321,371 @@ This is useful when you want to serialize a typed tuple or enum variant with ano class AsTupleStruct(val a: Boolean, val b: Boolean) assertEquals(listOf(true, false), Scale.encode(AsTupleStruct(true, false))) -``` \ No newline at end of file +``` + +--- + +## Binary Format (BinaryScale) + +The `BinaryScale` format provides direct binary encoding/decoding following the SCALE codec specification. Unlike the dynamic `Scale` format, `BinaryScale` converts Kotlin types directly to/from SCALE-encoded byte arrays. + +### Usage + +1. Declare a new type that you want to convert to/from + +```kotlin +@Serializable +class Test(val a: Boolean) +``` + +2. Use `BinaryScale` object to encode and decode instances of `Test` + +```kotlin +val value = Test(a = true) +val encoded: ByteArray = BinaryScale.encodeToByteArray(value) +val decoded: Test = BinaryScale.decodeFromByteArray(encoded) + +assert(value == decoded) +``` + +### Binary Types + +#### Primitives + +##### Booleans + +Booleans encode to a single byte: `0x00` for `false`, `0x01` for `true` + +```kotlin +val encoded = BinaryScale.encodeToByteArray(true) +assert(encoded contentEquals byteArrayOf(0x01)) + +val decoded = BinaryScale.decodeFromByteArray(byteArrayOf(0x00)) +assert(decoded == false) +``` + +##### Numbers + +All numeric types are encoded in little-endian byte order: +- `Byte` (i8): 1 byte +- `Short` (i16): 2 bytes +- `Int` (i32): 4 bytes +- `Long` (i64): 8 bytes +- `UByte` (u8): 1 byte +- `UShort` (u16): 2 bytes +- `UInt` (u32): 4 bytes +- `ULong` (u64): 8 bytes + +```kotlin +@Serializable +data class Numbers( + val s1: Byte, val s2: Short, val s3: Int, val s4: Long, + val u1: UByte, val u2: UShort, val u3: UInt, val u4: ULong, +) + +val value = Numbers(1, 2, 3, 4, 5.toUByte(), 6.toUShort(), 7.toUInt(), 8.toULong()) +val encoded = BinaryScale.encodeToByteArray(value) +val decoded = BinaryScale.decodeFromByteArray(encoded) +assert(value == decoded) +``` + +`Float` and `Double` types are not supported since they have no equivalent in SCALE standard. + +#### Compact Integers + +`BigInteger` values are encoded using SCALE compact encoding, which provides efficient representation for integers of varying sizes. + +```kotlin +val number = 100.toBigInteger() +val encoded = BinaryScale.encodeToByteArray(number) +val decoded = BinaryScale.decodeFromByteArray(encoded) +assert(number == decoded) +``` + +When using `BigInteger` in a nested context (as a field of a class), use the `BigIntegerSerializable` typealias: + +```kotlin +@Serializable +data class TestData(val a: BigIntegerSerializable) + +val value = TestData(100.toBigInteger()) +val encoded = BinaryScale.encodeToByteArray(value) +``` + +#### Strings + +Strings are encoded as variable-length byte arrays with a compact-encoded length prefix followed by UTF-8 encoded bytes. + +```kotlin +val value = "Test" +val encoded = BinaryScale.encodeToByteArray(value) +val decoded = BinaryScale.decodeFromByteArray(encoded) +assert(value == decoded) +``` + +#### Byte Arrays + +Byte arrays can be encoded in two ways: + +##### Variable Length + +By default, byte arrays are encoded with a compact-encoded length prefix followed by the bytes: + +```kotlin +val value = ByteArray(25) { it.toByte() } +val encoded = BinaryScale.encodeToByteArray(value) +val decoded = BinaryScale.decodeFromByteArray(encoded) +assert(value contentEquals decoded) +``` + +##### Fixed Length + +Use the `@FixedLength` annotation to encode byte arrays without a length prefix: + +```kotlin +@Serializable +class TestData(@FixedLength(20) val bytes: ByteArray) + +val value = ByteArray(20) { it.toByte() } +val encoded = BinaryScale.encodeToByteArray(TestData(value)) +// encoded contains exactly 20 bytes, no length prefix +``` + +For generic types and top-level encoding, use the wrapper classes: + +```kotlin +val bytes = ByteArray(20) { 1 } +val encoded = BinaryScale.encodeToByteArray(WithLength20(bytes)) +val decoded = BinaryScale.decodeFromByteArray>(encoded) +``` + +Available wrapper classes: `WithLength20`, `WithLength32`, `WithLength64` + +When using `ByteArray` in a nested context, you can use the `ByteArraySerializable` typealias for consistency: + +```kotlin +@JvmInline +@Serializable +value class Wrapper(val a: ByteArraySerializable) +``` + +#### Lists + +Lists can be encoded in two ways, similar to byte arrays: + +##### Variable Length + +By default, lists are encoded with a compact-encoded length prefix followed by the elements: + +```kotlin +val value = listOf(true, false, true) +val encoded = BinaryScale.encodeToByteArray(value) +val decoded = BinaryScale.decodeFromByteArray>(encoded) +assert(value == decoded) +``` + +##### Fixed Length + +Use the `@FixedLength` annotation to encode lists without a length prefix: + +```kotlin +@Serializable +data class TestData(@FixedLength(20) val list: List) + +val value = (0 until 20).map { true } +val encoded = BinaryScale.encodeToByteArray(TestData(value)) +``` + +For generic types, use wrapper classes: + +```kotlin +val value = (0 until 20).map { true } +val encoded = BinaryScale.encodeToByteArray(WithLength20(value)) +``` + +#### Optional Types + +Nullable types are encoded with a prefix byte indicating presence: +- `0x00` for `null` +- `0x01` followed by the encoded value for non-null values + +```kotlin +val nullValue: Byte? = null +val encoded = BinaryScale.encodeToByteArray(nullValue) +assert(encoded contentEquals byteArrayOf(0x00)) + +val someValue: Byte? = 0x12 +val encoded2 = BinaryScale.encodeToByteArray(someValue) +assert(encoded2 contentEquals byteArrayOf(0x01, 0x12)) +``` + +##### Optional Booleans + +Optional booleans use a special encoding with three states: +- `0x00` for `null` +- `0x01` for `false` +- `0x02` for `true` + +```kotlin +@Serializable +data class TestData(val a: Boolean?) + +val encoded1 = BinaryScale.encodeToByteArray(TestData(null)) +assert(encoded1 contentEquals byteArrayOf(0x00)) + +val encoded2 = BinaryScale.encodeToByteArray(TestData(false)) +assert(encoded2 contentEquals byteArrayOf(0x01)) + +val encoded3 = BinaryScale.encodeToByteArray(TestData(true)) +assert(encoded3 contentEquals byteArrayOf(0x02)) +``` + +#### Classes + +Regular classes encode their fields in declaration order without field names. They should be marked with `@Serializable` to be recognized by the plugin. + +```kotlin +@Serializable +data class Person(val age: Byte, val active: Boolean) + +val value = Person(age = 25, active = true) +val encoded = BinaryScale.encodeToByteArray(value) +// Encoded as: [0x19, 0x01] (25 followed by true) + +val decoded = BinaryScale.decodeFromByteArray(encoded) +assert(value == decoded) +``` + +#### Objects + +Objects encode to an empty byte array since they carry no data: + +```kotlin +@Serializable +object Singleton + +val encoded = BinaryScale.encodeToByteArray(Singleton) +assert(encoded.isEmpty()) +``` + +#### Value Classes + +Value classes are transparent for encoding and encode to their inner values: + +```kotlin +@JvmInline +@Serializable +value class UserId(val value: Int) + +val userId = UserId(42) +val encoded = BinaryScale.encodeToByteArray(userId) +// Encoded same as Int(42) + +val decoded = BinaryScale.decodeFromByteArray(encoded) +assert(userId == decoded) +``` + +#### Enums + +Regular enums encode to a single byte representing their ordinal (0-indexed position): + +```kotlin +@Serializable +enum class Status { + PENDING, ACTIVE, COMPLETED +} + +val encoded = BinaryScale.encodeToByteArray(Status.ACTIVE) +assert(encoded contentEquals byteArrayOf(0x01)) // ACTIVE is at index 1 +``` + +You can customize the index using the `@EnumIndex` annotation: + +```kotlin +@Serializable +enum class Priority { + @EnumIndex(10) + LOW, + @EnumIndex(20) + MEDIUM, + @EnumIndex(30) + HIGH +} + +val encoded = BinaryScale.encodeToByteArray(Priority.MEDIUM) +assert(encoded contentEquals byteArrayOf(0x14)) // 0x14 = 20 +``` + +#### Sealed Classes (Discriminated Unions) + +Sealed hierarchies encode as a variant index byte followed by the variant's data. Use the `@EnumIndex` annotation to specify variant indices: + +```kotlin +@Serializable +sealed class Result { + + @Serializable + @EnumIndex(0) + object Empty : Result() + + @Serializable + @EnumIndex(1) + data class Value(val data: Boolean) : Result() + + @Serializable + @EnumIndex(2) + data class Pair(val a: Boolean, val b: Boolean) : Result() +} + +// Object variant: just the index +val encoded1 = BinaryScale.encodeToByteArray(Result.Empty) +assert(encoded1 contentEquals byteArrayOf(0x00)) + +// Single field variant: index + field value +val encoded2 = BinaryScale.encodeToByteArray(Result.Value(true)) +assert(encoded2 contentEquals byteArrayOf(0x01, 0x01)) + +// Multiple fields variant: index + all fields in order +val encoded3 = BinaryScale.encodeToByteArray(Result.Pair(true, false)) +assert(encoded3 contentEquals byteArrayOf(0x02, 0x01, 0x00)) +``` + +### Annotations + +#### `@EnumIndex` + +Specifies a custom index for enum entries or sealed class variants. Can be applied to: +- Enum entries +- Sealed class subclasses + +```kotlin +@Serializable +enum class CustomEnum { + @EnumIndex(2) + A, + @EnumIndex(1) + B, + @EnumIndex(0) + C +} + +// Encodes to their custom indices, not their ordinal positions +``` + +#### `@FixedLength` + +Instructs the encoder/decoder to skip the length prefix for collections (byte arrays and lists). The length must be known at compile time. + +```kotlin +@Serializable +data class FixedData( + @FixedLength(32) val hash: ByteArray, + @FixedLength(10) val items: List +) +``` + +For generic type parameters, use the wrapper classes `WithLength20`, `WithLength32`, or `WithLength64`. + +#### Using `ByteArray` or `BigInteger` in a struct + +When using `ByteArray` or `BigInteger` in a nested context (as a field of a class), you should use the following typealiases: `ByteArraySerializable` and `BigIntegerSerializable`. Otherwise the code will not compile / encoding won't work properly. \ No newline at end of file diff --git a/koltinx-serialization-scale/build.gradle b/koltinx-serialization-scale/build.gradle index af4da923..50a6470d 100644 --- a/koltinx-serialization-scale/build.gradle +++ b/koltinx-serialization-scale/build.gradle @@ -1,7 +1,7 @@ plugins { id 'com.android.library' id 'kotlin-android' - id 'org.jetbrains.kotlin.plugin.serialization' version '2.1.21' + id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21' } apply from: '../maven-publish-helper.gradle' diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/DynamicStructureFormat.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/DynamicStructureFormat.kt index abd74b21..61aff3cc 100644 --- a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/DynamicStructureFormat.kt +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/DynamicStructureFormat.kt @@ -3,7 +3,7 @@ package io.novasama.substrate_sdk_android.koltinx_serialization_scale import io.novasama.substrate_sdk_android.koltinx_serialization_scale.decoder.PrimitiveDecoder import io.novasama.substrate_sdk_android.koltinx_serialization_scale.encoder.RootEncoder import io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializers.BigIntegerSerializer -import io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializers.ByteArraySerializer +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializers.ByteArrayDynamicStructSerializer import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer @@ -44,7 +44,7 @@ fun DynamicStructureFormat.decode(type: KType, dynamicStructure: Any?): T { private fun SerializersModule.modifiedSerializer(kType: KType): KSerializer { return when { // We need to overwrite built-in serializer for ByteArray - kType == typeOf() -> ByteArraySerializer as KSerializer + kType == typeOf() -> ByteArrayDynamicStructSerializer as KSerializer else -> serializer(kType) as KSerializer } } @@ -72,5 +72,5 @@ open class Scale( private val defaultSerializers = SerializersModule { contextual(BigInteger::class, BigIntegerSerializer) - contextual(ByteArray::class, ByteArraySerializer) + contextual(ByteArray::class, ByteArrayDynamicStructSerializer) } diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/annotations/AsDictEnum.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/annotations/AsDictEnum.kt index 887894f3..e6e635c0 100644 --- a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/annotations/AsDictEnum.kt +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/annotations/AsDictEnum.kt @@ -24,4 +24,4 @@ import kotlinx.serialization.SerialInfo @OptIn(ExperimentalSerializationApi::class) @Target(AnnotationTarget.CLASS) @SerialInfo -annotation class AsDictEnum \ No newline at end of file +annotation class AsDictEnum diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/BinaryScale.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/BinaryScale.kt new file mode 100644 index 00000000..f7edcac2 --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/BinaryScale.kt @@ -0,0 +1,68 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary + +import io.emeraldpay.polkaj.scale.ScaleCodecReader +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder.PrimitiveBinaryScaleDecoder +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.encoder.PrimitiveBinaryScaleEncoder +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.serializers.BigIntegerBinarySerializer +import io.novasama.substrate_sdk_android.runtime.definitions.types.useScaleWriter +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.plus +import kotlinx.serialization.serializer +import java.math.BigInteger +import kotlin.reflect.KType +import kotlin.reflect.typeOf + +inline fun BinaryScale.decodeFromByteArray(bytes: ByteArray): T { + return decodeFromByteArray(typeOf(), bytes) +} + +inline fun BinaryScale.encodeToByteArray(value: T): ByteArray { + return encodeToByteArray(typeOf(), value) +} + +@Suppress("UNCHECKED_CAST") +fun BinaryScale.decodeFromByteArray(type: KType, bytes: ByteArray): T { + return decodeFromByteArray(serializersModule.serializer(type) as KSerializer, bytes) +} + +@Suppress("UNCHECKED_CAST") +fun BinaryScale.encodeToByteArray(type: KType, value: T): ByteArray { + return encodeToByteArray(serializersModule.serializer(type) as KSerializer, value) +} + +open class BinaryScale( + serializersModules: SerializersModule +) : BinaryFormat { + + override val serializersModule: SerializersModule = defaultSerializers + serializersModules + + override fun decodeFromByteArray( + deserializer: DeserializationStrategy, + bytes: ByteArray + ): T { + val scaleReader = ScaleCodecReader(bytes) + val decoder = PrimitiveBinaryScaleDecoder(serializersModule, scaleReader, null, null) + return decoder.decodeSerializableValue(deserializer) + } + + override fun encodeToByteArray(serializer: SerializationStrategy, value: T): ByteArray { + return useScaleWriter { + val encoder = PrimitiveBinaryScaleEncoder(serializersModule, this@useScaleWriter, null) + encoder.encodeSerializableValue(serializer, value) + } + } + + companion object Default : BinaryScale(EmptySerializersModule()) +} + +private val defaultSerializers = SerializersModule { + contextual(BigInteger::class, BigIntegerBinarySerializer) +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/ElementDeclarationContext.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/ElementDeclarationContext.kt new file mode 100644 index 00000000..69f1d34f --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/ElementDeclarationContext.kt @@ -0,0 +1,19 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.utils.findAnnotation +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor + +class ElementDeclarationContext( + val index: Int, + val descriptor: SerialDescriptor, +) + +val ElementDeclarationContext.elementAnnotations: List + get() = descriptor.getElementAnnotations(index) + +inline fun ElementDeclarationContext.findElementAnnotation(): T? { + return elementAnnotations.findAnnotation() +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/annotations/Enums.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/annotations/Enums.kt new file mode 100644 index 00000000..0ec4db48 --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/annotations/Enums.kt @@ -0,0 +1,7 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations + +import kotlinx.serialization.SerialInfo + +@SerialInfo +@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FIELD, AnnotationTarget.CLASS) +annotation class EnumIndex(val index: Int) diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/annotations/FixedLength.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/annotations/FixedLength.kt new file mode 100644 index 00000000..2143b99e --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/annotations/FixedLength.kt @@ -0,0 +1,28 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialInfo +import kotlinx.serialization.Serializable + +/** + * Can be used on ByteArray and List in order to instruct encoding process + * to not to write the length of the collection to the output + */ +@SerialInfo +@Target(AnnotationTarget.PROPERTY) +annotation class FixedLength(val length: Int) + +/** + * A wrapper classes around [FixedLength] can be applicable to generic type parameters and top-level types + */ +// Cannot be value class as FixedLength info in descriptor is lost in this case +@Serializable +data class WithLength20(@FixedLength(20) val value: T) + +@Serializable +data class WithLength32(@FixedLength(32) val value: T) + +@Serializable +data class WithLength64(@FixedLength(64) val value: T) diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/common/ScaleOptional.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/common/ScaleOptional.kt new file mode 100644 index 00000000..ec426c3b --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/common/ScaleOptional.kt @@ -0,0 +1,32 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common + +import io.emeraldpay.polkaj.scale.ScaleCodecWriter +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.NULL_MARK +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.OPTIONAL_FALSE +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.OPTIONAL_TRUE +import io.novasama.substrate_sdk_android.scale.dataType.byte +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialDescriptor + +internal object ScaleOptional { + + const val NULL_MARK: Byte = 0 + + const val NOT_NULL_MARK: Byte = 1 + + const val OPTIONAL_FALSE: Byte = 1 + const val OPTIONAL_TRUE: Byte = 2 +} + +internal fun SerialDescriptor.isOptionalBoolean(): Boolean { + return kind == PrimitiveKind.BOOLEAN && isNullable +} + +internal fun ScaleCodecWriter.encodeOptionalBoolean(boolean: Boolean?) { + val byte = when (boolean) { + null -> NULL_MARK + true -> OPTIONAL_TRUE + false -> OPTIONAL_FALSE + } + writeByte(byte) +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/BaseCompositeBinaryDecoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/BaseCompositeBinaryDecoder.kt new file mode 100644 index 00000000..a4984809 --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/BaseCompositeBinaryDecoder.kt @@ -0,0 +1,101 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder + +import io.emeraldpay.polkaj.scale.ScaleCodecReader +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.ElementDeclarationContext +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.NULL_MARK +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.CompositeDecoder.Companion.DECODE_DONE +import kotlinx.serialization.encoding.Decoder + +abstract class BaseCompositeBinaryDecoder( + private val reader: ScaleCodecReader, +) : CompositeDecoder { + + private var currentIndex: Int = 0 + + abstract fun elementsCount(descriptor: SerialDescriptor): Int + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + return if (currentIndex < elementsCount(descriptor)) { + currentIndex++ + } else { + DECODE_DONE + } + } + + override fun decodeBooleanElement(descriptor: SerialDescriptor, index: Int): Boolean { + return reader.readBoolean() + } + + override fun decodeByteElement(descriptor: SerialDescriptor, index: Int): Byte { + return reader.readByte() + } + + override fun decodeCharElement(descriptor: SerialDescriptor, index: Int): Char { + unsupportedDecoding("Char") + } + + override fun decodeDoubleElement(descriptor: SerialDescriptor, index: Int): Double { + unsupportedDecoding("Double") + } + + override fun decodeFloatElement(descriptor: SerialDescriptor, index: Int): Float { + unsupportedDecoding("Float") + } + + @ExperimentalSerializationApi + override fun decodeInlineElement(descriptor: SerialDescriptor, index: Int): Decoder { + return PrimitiveBinaryScaleDecoder(serializersModule, reader, null, null) + } + + override fun decodeIntElement(descriptor: SerialDescriptor, index: Int): Int { + return ScaleCodecReader.INT32.read(reader) + } + + override fun decodeLongElement(descriptor: SerialDescriptor, index: Int): Long { + return reader.readLong() + } + + @ExperimentalSerializationApi + override fun decodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ): T? { + val nullabilityByte = reader.readByte() + if (nullabilityByte == NULL_MARK) return null + + val elementContext = ElementDeclarationContext(index, descriptor) + val delegate = PrimitiveBinaryScaleDecoder(serializersModule, reader, elementContext, nullabilityByte) + return delegate.decodeSerializableValue(deserializer) + } + + override fun decodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ): T { + val elementContext = ElementDeclarationContext(index, descriptor) + val delegate = PrimitiveBinaryScaleDecoder(serializersModule, reader, elementContext, null) + return delegate.decodeSerializableValue(deserializer) + } + + override fun decodeShortElement(descriptor: SerialDescriptor, index: Int): Short { + return reader.readShort() + } + + override fun decodeStringElement(descriptor: SerialDescriptor, index: Int): String { + return reader.readString() + } + + override fun endStructure(descriptor: SerialDescriptor) {} + + private fun unsupportedDecoding(type: String): Nothing { + error("Decoding $type is not supported") + } +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/BinaryScaleDecoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/BinaryScaleDecoder.kt new file mode 100644 index 00000000..6e2f7a4e --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/BinaryScaleDecoder.kt @@ -0,0 +1,9 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder + +import kotlinx.serialization.encoding.Decoder +import java.math.BigInteger + +interface BinaryScaleDecoder : Decoder { + + fun decodeCompact(): BigInteger +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/ByteArrays.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/ByteArrays.kt new file mode 100644 index 00000000..6396d23a --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/ByteArrays.kt @@ -0,0 +1,10 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder + +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.descriptors.SerialDescriptor + +private val byteArraySerializer = ByteArraySerializer().descriptor + +fun SerialDescriptor.isByteArrayDescriptor(): Boolean { + return this === byteArraySerializer +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/FixedLengthListBinaryDecoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/FixedLengthListBinaryDecoder.kt new file mode 100644 index 00000000..690fefea --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/FixedLengthListBinaryDecoder.kt @@ -0,0 +1,16 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder + +import io.emeraldpay.polkaj.scale.ScaleCodecReader +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.modules.SerializersModule + +class FixedLengthListBinaryDecoder( + private val length: Int, + private val reader: ScaleCodecReader, + override val serializersModule: SerializersModule +) : BaseCompositeBinaryDecoder(reader) { + + override fun elementsCount(descriptor: SerialDescriptor): Int { + return length + } +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/ObjectBinaryDecoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/ObjectBinaryDecoder.kt new file mode 100644 index 00000000..780ef55f --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/ObjectBinaryDecoder.kt @@ -0,0 +1,15 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder + +import io.emeraldpay.polkaj.scale.ScaleCodecReader +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.modules.SerializersModule + +class ObjectBinaryDecoder( + reader: ScaleCodecReader, + override val serializersModule: SerializersModule, +) : BaseCompositeBinaryDecoder(reader) { + + override fun elementsCount(descriptor: SerialDescriptor): Int { + return 0 + } +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/PrimitiveBinaryScaleDecoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/PrimitiveBinaryScaleDecoder.kt new file mode 100644 index 00000000..4c528b9a --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/PrimitiveBinaryScaleDecoder.kt @@ -0,0 +1,206 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder + +import io.emeraldpay.polkaj.scale.ScaleCodecReader +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.ElementDeclarationContext +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.EnumIndex +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.FixedLength +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.NULL_MARK +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.OPTIONAL_FALSE +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.OPTIONAL_TRUE +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.findElementAnnotation +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.decoder.StubCompositeDecoder +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.utils.findAnnotation +import io.novasama.substrate_sdk_android.scale.dataType.compactInt +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.SealedClassSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.descriptors.elementDescriptors +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.findPolymorphicSerializer +import kotlinx.serialization.modules.SerializersModule +import java.math.BigInteger + +@OptIn(InternalSerializationApi::class) +class PrimitiveBinaryScaleDecoder( + override val serializersModule: SerializersModule, + private val reader: ScaleCodecReader, + private val elementContext: ElementDeclarationContext?, + nullabilityByteFromParent: Byte? // non null in case the upper type level was "option" +) : BinaryScaleDecoder { + + private var nullabilityByte: Byte? = nullabilityByteFromParent + + override fun decodeCompact(): BigInteger { + return compactInt.read(reader) + } + + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { + return when (val kind = descriptor.kind) { + StructureKind.CLASS -> StructDecoder(reader, serializersModule) + StructureKind.LIST -> createListDecoder() + StructureKind.OBJECT -> ObjectBinaryDecoder(reader, serializersModule) + else -> error("Unsupported descriptor kind: $kind") + } + } + + @Suppress("UNCHECKED_CAST") + override fun decodeSerializableValue(deserializer: DeserializationStrategy): T { + return when { + deserializer.descriptor.isByteArrayDescriptor() -> decodeByteArray() as T + deserializer is SealedClassSerializer -> decodePolymorphic(deserializer as SealedClassSerializer) + else -> return super.decodeSerializableValue(deserializer) + } + } + + private fun createListDecoder() : CompositeDecoder { + val fixedSize = elementContext?.findElementAnnotation()?.length + + return if (fixedSize != null) { + FixedLengthListBinaryDecoder(fixedSize, reader, serializersModule) + } else { + VariableLengthListBinaryDecoder(reader, serializersModule) + } + } + + private fun decodeByteArray(): ByteArray { + val fixedSize = elementContext?.findElementAnnotation()?.length + + return if (fixedSize != null) { + reader.readByteArray(fixedSize) + } else { + reader.readByteArray() + } + } + + override fun decodeBoolean(): Boolean { + // Option uses single byte encoding, so we check previously read mark + return when (val data = nullabilityByte) { + null -> reader.readBoolean() + OPTIONAL_FALSE -> false + OPTIONAL_TRUE -> true + else -> error("Invalid value read for type `Boolean?`: $data") + } + } + + override fun decodeByte(): Byte { + return reader.readByte() + } + + override fun decodeChar(): Char { + unsupportedDecoding("Char") + } + + override fun decodeDouble(): Double { + unsupportedDecoding("Double") + } + + override fun decodeEnum(enumDescriptor: SerialDescriptor): Int { + val index = reader.readByte().toInt() + + if (enumDescriptor.enumElementUsesIndexDirectly(index)) { + return index + } + + return enumDescriptor.findRealEnumElementIndex(index) + ?: index // Return index as the fallback so kotlinx serialization will throw readable error + } + + private fun SerialDescriptor.enumElementUsesIndexDirectly(index: Int): Boolean { + return try { + val customEnumIndex = getElementAnnotations(index).findAnnotation() + ?.index + + customEnumIndex == null || customEnumIndex == index + } catch (_: IndexOutOfBoundsException) { + false + } + } + + private fun SerialDescriptor.findRealEnumElementIndex(customIndex: Int): Int? { + for (i in 0 until elementsCount) { + val indexFromAnnotation = getElementAnnotations(i) + .findAnnotation() + ?.index + + if (indexFromAnnotation == customIndex) return i + } + + return null + } + + override fun decodeFloat(): Float { + unsupportedDecoding("Float") + } + + @ExperimentalSerializationApi + override fun decodeInline(descriptor: SerialDescriptor): Decoder { + return this + } + + override fun decodeInt(): Int { + return ScaleCodecReader.INT32.read(reader) + } + + override fun decodeLong(): Long { + return reader.readLong() + } + + @ExperimentalSerializationApi + override fun decodeNotNullMark(): Boolean { + nullabilityByte = reader.readByte() + return nullabilityByte != NULL_MARK + } + + @ExperimentalSerializationApi + override fun decodeNull(): Nothing? { + return null + } + + override fun decodeShort(): Short { + return reader.readShort() + } + + override fun decodeString(): String { + return reader.readString() + } + + private fun unsupportedDecoding(type: String): Nothing { + error("Decoding $type is not supported") + } + + private fun decodePolymorphic(serializer: SealedClassSerializer): T { + // Get the second element from descriptor which contains info about subclasses + // See source code of SealedClassSerializer.descriptor for more details + val subclassesDesc = serializer.descriptor.getElementDescriptor(1) + + val readIndex = reader.readByte().toInt() + val indexInSealedHierarchy = subclassesDesc.findRequiredSealedHierarchyIndex(readIndex) + + val actualName = subclassesDesc.getElementName(indexInSealedHierarchy) + val actualSerializer = serializer.findPolymorphicSerializer(StubCompositeDecoder(serializersModule), actualName) + + return actualSerializer.deserialize(this) + } + + private fun SerialDescriptor.findRequiredSealedHierarchyIndex(customIndex: Int): Int { + return elementDescriptors.indexOfFirst { descriptor -> + val indexFromAnnotation = descriptor + .findAnnotation() + ?.index + + indexFromAnnotation == customIndex + }.also { + if (it == -1) { + throw SerializationException("@EnumIndex annotation is required for sealed hierarchies") + } + } + } + +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/StructDecoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/StructDecoder.kt new file mode 100644 index 00000000..489b6ba5 --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/StructDecoder.kt @@ -0,0 +1,18 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder + +import io.emeraldpay.polkaj.scale.ScaleCodecReader +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.modules.SerializersModule + +class StructDecoder( + private val reader: ScaleCodecReader, + override val serializersModule: SerializersModule, +) : BaseCompositeBinaryDecoder(reader) { + + override fun elementsCount(descriptor: SerialDescriptor): Int { + return descriptor.elementsCount + } +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/VariableLengthListBinaryDecoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/VariableLengthListBinaryDecoder.kt new file mode 100644 index 00000000..ccbd89fb --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/decoder/VariableLengthListBinaryDecoder.kt @@ -0,0 +1,17 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder + +import io.emeraldpay.polkaj.scale.ScaleCodecReader +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.modules.SerializersModule + +class VariableLengthListBinaryDecoder( + private val reader: ScaleCodecReader, + override val serializersModule: SerializersModule +) : BaseCompositeBinaryDecoder(reader) { + + private val size = reader.readCompactInt() + + override fun elementsCount(descriptor: SerialDescriptor): Int { + return size + } +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/encoder/BinaryScaleEncoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/encoder/BinaryScaleEncoder.kt new file mode 100644 index 00000000..66470249 --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/encoder/BinaryScaleEncoder.kt @@ -0,0 +1,9 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.encoder + +import kotlinx.serialization.encoding.Encoder +import java.math.BigInteger + +interface BinaryScaleEncoder : Encoder { + + fun encodeCompact(compact: BigInteger) +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/encoder/CompositeBinaryEncoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/encoder/CompositeBinaryEncoder.kt new file mode 100644 index 00000000..14992c26 --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/encoder/CompositeBinaryEncoder.kt @@ -0,0 +1,150 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.encoder + +import io.emeraldpay.polkaj.scale.ScaleCodecWriter +import io.novasama.substrate_sdk_android.extensions.toSignedBytes +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.ElementDeclarationContext +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.NOT_NULL_MARK +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.NULL_MARK +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.encodeOptionalBoolean +import io.novasama.substrate_sdk_android.scale.utils.directWrite +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.CompositeEncoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.SerializersModule +import java.nio.ByteOrder + +internal class CompositeBinaryEncoder( + override val serializersModule: SerializersModule, + private val writer: ScaleCodecWriter, +) : CompositeEncoder { + + override fun endStructure(descriptor: SerialDescriptor) {} + + override fun encodeBooleanElement( + descriptor: SerialDescriptor, + index: Int, + value: Boolean + ) { + ScaleCodecWriter.BOOL.write(writer, value) + } + + override fun encodeByteElement( + descriptor: SerialDescriptor, + index: Int, + value: Byte + ) { + writer.writeByte(value) + } + + override fun encodeShortElement( + descriptor: SerialDescriptor, + index: Int, + value: Short + ) { + writer.writeShort(value) + } + + override fun encodeCharElement( + descriptor: SerialDescriptor, + index: Int, + value: Char + ) { + unsupported("Char") + } + + override fun encodeIntElement( + descriptor: SerialDescriptor, + index: Int, + value: Int + ) { + // TODO can be greatly optimized + val bytes = value.toBigInteger().toSignedBytes( + resultByteOrder = ByteOrder.LITTLE_ENDIAN, + expectedBytesSize = 4 + ) + + writer.directWrite(bytes) + } + + override fun encodeLongElement( + descriptor: SerialDescriptor, + index: Int, + value: Long + ) { + writer.writeLong(value) + } + + override fun encodeFloatElement( + descriptor: SerialDescriptor, + index: Int, + value: Float + ) { + unsupported("Float") + } + + override fun encodeDoubleElement( + descriptor: SerialDescriptor, + index: Int, + value: Double + ) { + unsupported("Double") + } + + override fun encodeStringElement( + descriptor: SerialDescriptor, + index: Int, + value: String + ) { + writer.writeString(value) + } + + override fun encodeInlineElement( + descriptor: SerialDescriptor, + index: Int + ): Encoder { + val elementContext = ElementDeclarationContext(index, descriptor) + return PrimitiveBinaryScaleEncoder(serializersModule, writer, elementContext) + } + + override fun encodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T + ) { + val elementContext = ElementDeclarationContext(index, descriptor) + val delegate = PrimitiveBinaryScaleEncoder(serializersModule, writer, elementContext) + delegate.encodeSerializableValue(serializer, value) + } + + @ExperimentalSerializationApi + override fun encodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T? + ) { + when { + serializer.descriptor.kind == PrimitiveKind.BOOLEAN -> { + writer.encodeOptionalBoolean(value as Boolean?) + } + + value == null -> { + writer.writeByte(NULL_MARK) + } + + else -> { + writer.writeByte(NOT_NULL_MARK) + encodeSerializableElement(descriptor, index, serializer, value) + } + } + } + + private fun unsupported(label: String): Nothing { + throw SerializationException("Encoding of $label is not supported") + } +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/encoder/PrimitiveBinaryScaleEncoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/encoder/PrimitiveBinaryScaleEncoder.kt new file mode 100644 index 00000000..d4ad1a87 --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/encoder/PrimitiveBinaryScaleEncoder.kt @@ -0,0 +1,191 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.encoder + +import io.emeraldpay.polkaj.scale.ScaleCodecWriter +import io.novasama.substrate_sdk_android.extensions.toSignedBytes +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.ElementDeclarationContext +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.EnumIndex +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.FixedLength +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.NOT_NULL_MARK +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.ScaleOptional.NULL_MARK +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.encodeOptionalBoolean +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.common.isOptionalBoolean +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder.isByteArrayDescriptor +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.findElementAnnotation +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.utils.findAnnotation +import io.novasama.substrate_sdk_android.scale.dataType.compactInt +import io.novasama.substrate_sdk_android.scale.utils.directWrite +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.SealedClassSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.encoding.CompositeEncoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.findPolymorphicSerializer +import kotlinx.serialization.modules.SerializersModule +import java.math.BigInteger +import java.nio.ByteOrder + +internal class PrimitiveBinaryScaleEncoder( + override val serializersModule: SerializersModule, + private val writer: ScaleCodecWriter, + private val elementContext: ElementDeclarationContext?, +) : BinaryScaleEncoder { + + @ExperimentalSerializationApi + override fun encodeNull() { + writer.writeByte(NULL_MARK) + } + + @ExperimentalSerializationApi + override fun encodeNotNullMark() { + writer.writeByte(NOT_NULL_MARK) + } + + override fun encodeBoolean(value: Boolean) { + ScaleCodecWriter.BOOL.write(writer, value) + } + + override fun encodeCompact(compact: BigInteger) { + compactInt.write(writer, compact) + } + + override fun encodeByte(value: Byte) { + writer.writeByte(value) + } + + override fun encodeShort(value: Short) { + writer.writeShort(value) + } + + override fun encodeChar(value: Char) { + unsupported("Char") + } + + override fun encodeInt(value: Int) { + // TODO can be greatly optimized + val bytes = value.toBigInteger().toSignedBytes( + resultByteOrder = ByteOrder.LITTLE_ENDIAN, + expectedBytesSize = 4 + ) + + writer.directWrite(bytes) + } + + override fun encodeLong(value: Long) { + writer.writeLong(value) + } + + override fun encodeFloat(value: Float) { + unsupported("Float") + } + + override fun encodeDouble(value: Double) { + unsupported("Double") + } + + override fun encodeString(value: String) { + writer.writeString(value) + } + + override fun encodeEnum( + enumDescriptor: SerialDescriptor, + index: Int + ) { + val indexFromAnnotation = enumDescriptor.getElementAnnotations(index) + .findAnnotation() + ?.index + val indexToWrite = indexFromAnnotation ?: index + + writer.writeByte(indexToWrite.toByte()) + } + + + override fun encodeInline(descriptor: SerialDescriptor): Encoder { + return this + } + + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder { + return when (val kind = descriptor.kind) { + StructureKind.CLASS, + StructureKind.OBJECT, + // For LIST, length is written in encodeSerializableValue - where we can access the list itself + // to know its size + StructureKind.LIST -> CompositeBinaryEncoder(serializersModule, writer) + + else -> error("Unsupported descriptor kind: $kind") + } + } + + private fun maybeWriteListLength(collectionSize: Int) { + val fixedLength = elementContext?.findElementAnnotation()?.length + if (fixedLength != null) { + checkFixedLengthSize(fixedLength, collectionSize) + } else { + writer.writeCompact(collectionSize) + } + } + + @Suppress("UNCHECKED_CAST") + @OptIn(InternalSerializationApi::class) + override fun encodeSerializableValue(serializer: SerializationStrategy, value: T) { + val descriptor = serializer.descriptor + + return when { + descriptor.isOptionalBoolean() -> writer.encodeOptionalBoolean(value as Boolean?) + descriptor.isByteArrayDescriptor() -> encodeByteArray(value as ByteArray) + descriptor.isList() -> { + // Encode list size here since it is the last time we will have access to `value` + maybeWriteListLength((value as Collection<*>).size) + // And then delegate to default logic with `beginStructure` + super.encodeSerializableValue(serializer, value) + } + + serializer is SealedClassSerializer<*> -> encodePolymorphic(serializer as SealedClassSerializer, value as (T & Any)) + else -> super.encodeSerializableValue(serializer, value) + } + } + + @OptIn(InternalSerializationApi::class) + private fun encodePolymorphic( + serializer: SealedClassSerializer, + value: T + ) { + val actualSerializer = serializer.findPolymorphicSerializer(this, value) + val index = actualSerializer.descriptor.findAnnotation()?.index + ?: throw SerializationException("@EnumIndex annotation is required for sealed hierarchies") + + writer.writeByte(index) + + actualSerializer.serialize(this, value) + } + + private fun encodeByteArray(byteArray: ByteArray) { + val fixedSize = elementContext?.findElementAnnotation()?.length + + return if (fixedSize != null) { + checkFixedLengthSize(fixedSize, byteArray.size) + + writer.directWrite(byteArray) + } else { + writer.writeByteArray(byteArray) + } + } + + private fun checkFixedLengthSize(sizeFromAnnotation: Int, actual: Int) { + if (sizeFromAnnotation != actual) { + val msg = "Size mismatch. Specified in @FixedLength: $sizeFromAnnotation. Got: $actual" + throw SerializationException(msg) + } + } + + private fun SerialDescriptor.isList(): Boolean { + return kind == StructureKind.LIST + } + + private fun unsupported(label: String): Nothing { + throw SerializationException("Encoding of $label is not supported") + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/serializers/BigIntegerBinarySerializer.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/serializers/BigIntegerBinarySerializer.kt new file mode 100644 index 00000000..a98d89ff --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/binary/serializers/BigIntegerBinarySerializer.kt @@ -0,0 +1,27 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.serializers + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decoder.BinaryScaleDecoder +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.encoder.BinaryScaleEncoder +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 +import java.math.BigInteger + +object BigIntegerBinarySerializer : KSerializer { + + override fun deserialize(decoder: Decoder): BigInteger { + require(decoder is BinaryScaleDecoder) + + return decoder.decodeCompact() + } + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BigInteger", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: BigInteger) { + require(encoder is BinaryScaleEncoder) + encoder.encodeCompact(value) + } +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decoder/PrimitiveDecoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decoder/PrimitiveDecoder.kt index 21da5088..e313aa84 100644 --- a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decoder/PrimitiveDecoder.kt +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decoder/PrimitiveDecoder.kt @@ -76,7 +76,7 @@ open class PrimitiveDecoder( } private fun detectEnumEntryName(): String { - return when(value) { + return when (value) { is String -> value is DictEnum.Entry<*> -> { require(value.value == null) { @@ -143,7 +143,7 @@ open class PrimitiveDecoder( val enumEntry = value as DictEnum.Entry<*> - val stubDecoder = StubCompositeDecoder() + val stubDecoder = StubCompositeDecoder(serializersModule) val variantClassName = createClassName(serializer.descriptor, enumEntry.name) @@ -163,7 +163,7 @@ open class PrimitiveDecoder( val fallback = descriptor.findSerializedFallback() ?: return null val fallbackClassName = createClassName(descriptor, fallback) - return findPolymorphicSerializerOrNull(StubCompositeDecoder(), fallbackClassName) + return findPolymorphicSerializerOrNull(StubCompositeDecoder(serializersModule), fallbackClassName) ?: error("Subtype $fallbackClassName specified as fallback via @FallbackAnnotation is not registered") } @@ -190,20 +190,4 @@ open class PrimitiveDecoder( } } } - - // This is needed because `findPolymorphicSerializerOrNull` only accepts `CompositeDecoder` - // whereas actually only using `serializersModule` under the hood - private inner class StubCompositeDecoder : BaseCompositeDecoder() { - - override val serializersModule: SerializersModule - get() = this@PrimitiveDecoder.serializersModule - - override fun decodeIdentity(descriptor: SerialDescriptor, index: Int): Any? { - error("STUB") - } - - override fun decodeElementIndex(descriptor: SerialDescriptor): Int { - error("STUB") - } - } } diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decoder/StubCompositeDecoder.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decoder/StubCompositeDecoder.kt new file mode 100644 index 00000000..393cd09d --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decoder/StubCompositeDecoder.kt @@ -0,0 +1,19 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decoder + +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.modules.SerializersModule + +// This is needed because `findPolymorphicSerializerOrNull` only accepts `CompositeDecoder` +// whereas actually only using `serializersModule` under the hood +internal class StubCompositeDecoder( + override val serializersModule: SerializersModule +) : BaseCompositeDecoder() { + + override fun decodeIdentity(descriptor: SerialDescriptor, index: Int): Any? { + error("STUB") + } + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + error("STUB") + } +} diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/BigIntegerSerializer.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/BigIntegerSerializer.kt index 2df13a71..fd9634ac 100644 --- a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/BigIntegerSerializer.kt +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/BigIntegerSerializer.kt @@ -2,8 +2,8 @@ package io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializer import io.novasama.substrate_sdk_android.koltinx_serialization_scale.decoder.ScaleDecoder import io.novasama.substrate_sdk_android.koltinx_serialization_scale.encoder.ScaleEncoder +import kotlinx.serialization.Contextual import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor @@ -11,7 +11,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.math.BigInteger -typealias BigIntegerSerializable = @Serializable(BigIntegerSerializer::class) BigInteger +typealias BigIntegerSerializable = @Contextual BigInteger object BigIntegerSerializer : KSerializer { diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/ByteArraySerializer.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/ByteArrayDynamicStructSerializer.kt similarity index 84% rename from koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/ByteArraySerializer.kt rename to koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/ByteArrayDynamicStructSerializer.kt index 1659c668..bf21cfc7 100644 --- a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/ByteArraySerializer.kt +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/ByteArrayDynamicStructSerializer.kt @@ -3,16 +3,13 @@ package io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializer import io.novasama.substrate_sdk_android.koltinx_serialization_scale.decoder.ScaleDecoder import io.novasama.substrate_sdk_android.koltinx_serialization_scale.encoder.ScaleEncoder import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable 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 -typealias ByteArraySerializable = @Serializable(ByteArraySerializer::class) ByteArray - -object ByteArraySerializer : KSerializer { +object ByteArrayDynamicStructSerializer : KSerializer { override fun deserialize(decoder: Decoder): ByteArray { require(decoder is ScaleDecoder) diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/ByteArraySerializable.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/ByteArraySerializable.kt new file mode 100644 index 00000000..7ffa5762 --- /dev/null +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/serializers/ByteArraySerializable.kt @@ -0,0 +1,5 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializers + +import kotlinx.serialization.Contextual + +typealias ByteArraySerializable = @Contextual ByteArray diff --git a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/utils/Annotations.kt b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/utils/Annotations.kt index 901d7cce..ca8b6367 100644 --- a/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/utils/Annotations.kt +++ b/koltinx-serialization-scale/src/main/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/utils/Annotations.kt @@ -5,8 +5,12 @@ package io.novasama.substrate_sdk_android.koltinx_serialization_scale.utils import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.descriptors.SerialDescriptor +inline fun List.findAnnotation(): T? { + return find { it is T } as? T +} + inline fun SerialDescriptor.findAnnotation(): T? { - return annotations.find { it is T } as? T ?: return null + return annotations.findAnnotation() } inline fun SerialDescriptor.isAnnotatedWith(): Boolean { diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/NestedGenericsIssue.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/NestedGenericsIssue.kt new file mode 100644 index 00000000..ed9eb004 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/NestedGenericsIssue.kt @@ -0,0 +1,65 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.BinaryScale +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.EnumIndex +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.FixedLength +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import org.junit.Assert +import org.junit.Test + + +@Serializable +sealed interface Sealed1 { + + @Serializable + @EnumIndex(0) + // When Sealed class subclass has List property, the serializer is constructed + // incorrectly. The issue seems to be related to List in particular. This can be workaround-ed by + // wrapping list into a value class + class A(@FixedLength(1) val sealed2: Sealed2List): Sealed1 +} + +@JvmInline +@Serializable +value class Sealed2List(val value: List>): List> by value + +@Serializable +sealed class Sealed2 { + + @Serializable + @EnumIndex(0) + class A(val value: T): Sealed2() +} + +class GenericTest { + + + + class GenericManager( + val serializer: KSerializer + ) { + + fun decode(byteArray: ByteArray): Sealed1 { + val genericSerializer = Sealed1.serializer(serializer) + return BinaryScale.decodeFromByteArray(genericSerializer, byteArray) + } + + fun encode(value: Sealed1): ByteArray { + val genericSerializer = Sealed1.serializer(serializer) + return BinaryScale.encodeToByteArray(genericSerializer, value) + } + } + + @Test + fun `should work`() { + val encodedExpected = byteArrayOf(0x00, 0x00, 0x00) + + val manager = GenericManager(Boolean.serializer()) + val encoded = manager.encode(Sealed1.A(Sealed2List(listOf(Sealed2.A(false))))) + Assert.assertArrayEquals(encodedExpected, encoded) + + manager.decode(encodedExpected) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/ByteArrayTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/ByteArrayTest.kt new file mode 100644 index 00000000..9783ea9d --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/ByteArrayTest.kt @@ -0,0 +1,31 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.Scale +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializers.ByteArraySerializable +import kotlinx.serialization.Serializable +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class ByteArrayTest : DecodeTest() { + + @Test + fun `should decode byte array`() { + val value = byteArrayOf(0x00, 0x01) + val result = Scale.decode(value) + + assertArrayEquals(value, result) + } + + @JvmInline + @Serializable + value class TestData(val byteArray: ByteArraySerializable) + + @Test + fun `should decode byte array as element`() { + val value = byteArrayOf(0x00, 0x01) + val result = Scale.decode(value) + + assertArrayEquals(value, result.byteArray) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/PrimitivesTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/PrimitivesTest.kt index d3422954..f4764502 100644 --- a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/PrimitivesTest.kt +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/PrimitivesTest.kt @@ -1,6 +1,7 @@ package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode import io.novasama.substrate_sdk_android.koltinx_serialization_scale.Scale +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode import io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode import org.junit.Assert.assertArrayEquals import org.junit.Test @@ -29,12 +30,4 @@ class PrimitivesTest : DecodeTest() { raw = true, expected = true ) - - @Test - fun `should decode byte array`() { - val value = byteArrayOf(0x00, 0x01) - val result = Scale.encode(value) - - assertArrayEquals(value, result as ByteArray) - } } \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/BinaryDecodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/BinaryDecodeTest.kt new file mode 100644 index 00000000..255e9889 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/BinaryDecodeTest.kt @@ -0,0 +1,14 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.BinaryScale +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decodeFromByteArray +import org.junit.Assert + +open class BinaryDecodeTest { + + inline fun runDecodeTest(raw: ByteArray, expected: T) { + val result: T = BinaryScale.decodeFromByteArray(raw) + + Assert.assertEquals(expected, result) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ByteArrayBinaryDecodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ByteArrayBinaryDecodeTest.kt new file mode 100644 index 00000000..d007441a --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ByteArrayBinaryDecodeTest.kt @@ -0,0 +1,63 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.BinaryScale +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.FixedLength +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.WithLength20 +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decodeFromByteArray +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializers.ByteArraySerializable +import io.novasama.substrate_sdk_android.scale.dataType.byteArray +import io.novasama.substrate_sdk_android.scale.dataType.string +import io.novasama.substrate_sdk_android.scale.dataType.toByteArray +import kotlinx.serialization.Serializable +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class ByteArrayBinaryDecodeTest : BinaryDecodeTest() { + + @Test + fun `should decode fixed byte array from annotation`() { + @Serializable + class TestData(@FixedLength(20) val bytes: ByteArray) + + val value = ByteArray(20) { it.toByte() } + val result = BinaryScale.decodeFromByteArray(value) + assertArrayEquals(value, result.bytes) + } + + @Test + fun `should decode variable length byte array`() { + val value = ByteArray(25) { it.toByte() } + val data = byteArray.toByteArray(value) + val result = BinaryScale.decodeFromByteArray(data) + assertArrayEquals(value, result) + } + + @JvmInline + @Serializable + value class ByteArraySerializableTestData(val a: ByteArraySerializable) + + @Test + fun `should keep compatibility with ByteArraySerializable`() { + val value = ByteArray(25) { it.toByte() } + val data = byteArray.toByteArray(value) + + val result = BinaryScale.decodeFromByteArray(data) + assertArrayEquals(value, result.a) + } + + @Test + fun `should decode string`() { + val expected = "Test" + val encoded = string.toByteArray(expected) + + runDecodeTest(raw =encoded, expected =expected) + } + + @Test + fun `WithFixedLength wrapper works`() { + val bytes = ByteArray(20) { 1 } + val result = BinaryScale.decodeFromByteArray>(bytes) + + assertArrayEquals(bytes, result.value) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/CompactBinaryDecodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/CompactBinaryDecodeTest.kt new file mode 100644 index 00000000..a2abfb18 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/CompactBinaryDecodeTest.kt @@ -0,0 +1,30 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializers.BigIntegerSerializable +import io.novasama.substrate_sdk_android.scale.dataType.compactInt +import io.novasama.substrate_sdk_android.scale.dataType.toByteArray +import kotlinx.serialization.Serializable +import org.junit.Test +import java.math.BigInteger + +class CompactBinaryDecodeTest : BinaryDecodeTest() { + + @Test + fun `should decode compact`() { + val number = 100.toBigInteger() + runDecodeTest(encodedOf(number), number) + } + + @Test + fun `should decode compact as element`() { + @Serializable + data class TestData(val a: BigIntegerSerializable) + + val number = 100.toBigInteger() + runDecodeTest(encodedOf(number), TestData(number)) + } + + private fun encodedOf(compact: BigInteger): ByteArray { + return compactInt.toByteArray(compact) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/DictEnumBinaryDecodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/DictEnumBinaryDecodeTest.kt new file mode 100644 index 00000000..6150bcba --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/DictEnumBinaryDecodeTest.kt @@ -0,0 +1,42 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.EnumIndex +import kotlinx.serialization.Serializable +import org.junit.Test + +class DictEnumBinaryDecodeTest : BinaryDecodeTest() { + + @Serializable + sealed class Sealed { + + @Serializable + @EnumIndex(0) + object Null : Sealed() + + @Serializable + @EnumIndex(1) + data class Single(val element: Boolean) : Sealed() + + @Serializable + @EnumIndex(2) + data class Double(val a: Boolean, val b: Boolean) : Sealed() + } + + @Test + fun `should decode variant object`() = runDecodeTest( + expected = Sealed.Null as Sealed, + raw = byteArrayOf(0x00) + ) + + @Test + fun `should decode variant value`() = runDecodeTest( + expected = Sealed.Single(true) as Sealed, + raw = byteArrayOf(0x01, 0x01) + ) + + @Test + fun `should decode variant struct`() = runDecodeTest( + expected = Sealed.Double(a = true, b = false) as Sealed, + raw = byteArrayOf(0x02, 0x01, 0x00) + ) +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/EnumBinaryDecodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/EnumBinaryDecodeTest.kt new file mode 100644 index 00000000..6d25212f --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/EnumBinaryDecodeTest.kt @@ -0,0 +1,56 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.BinaryScale +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.EnumIndex +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decodeFromByteArray +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import org.junit.Test + +class EnumBinaryDecodeTest : BinaryDecodeTest() { + + @Serializable + enum class TestData { + A, B, C, D + } + + @Test + fun `should decode enum entry`() { + runDecodeTest(byteArrayOf(0x00), TestData.A) + runDecodeTest(byteArrayOf(0x01), TestData.B) + runDecodeTest(byteArrayOf(0x02), TestData.C) + } + + @Test(expected = SerializationException::class) + fun `should throw for unknown variant`() { + BinaryScale.decodeFromByteArray(byteArrayOf(0x05)) + } + + @Serializable + enum class TestDataEnumIndex { + @EnumIndex(2) + A + } + + @Test + fun `should decode enum entry with custom index`() { + runDecodeTest(byteArrayOf(0x02), TestDataEnumIndex.A) + } + + @Serializable + enum class TestDataMismatchingIndices { + @EnumIndex(2) + A, + @EnumIndex(1) + B, + @EnumIndex(0) + C + } + + @Test + fun `should decode enum entry with mismatching indices`() { + runDecodeTest(byteArrayOf(0x02), TestDataMismatchingIndices.A) + runDecodeTest(byteArrayOf(0x01), TestDataMismatchingIndices.B) + runDecodeTest(byteArrayOf(0x00), TestDataMismatchingIndices.C) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ListDecodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ListDecodeTest.kt new file mode 100644 index 00000000..8ca163e4 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ListDecodeTest.kt @@ -0,0 +1,61 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.FixedLength +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.WithLength20 +import io.novasama.substrate_sdk_android.runtime.definitions.types.useScaleWriter +import io.novasama.substrate_sdk_android.scale.dataType.boolean +import kotlinx.serialization.Serializable +import org.junit.Test + +class ListDecodeTest : BinaryDecodeTest() { + + @Test + fun `should encode variable-length list`() { + val (expected, encoded) = variableLengthExpendedAndEncoded() + runDecodeTest(encoded, expected) + } + + @Test + fun `should encode variable-length list as element`() { + @Serializable + data class TestData(val list: List) + + val (expected, encoded) = variableLengthExpendedAndEncoded() + runDecodeTest(encoded, TestData(expected)) + } + + @Test + fun `should encode fixed-length list`() { + val (expected, encoded) = fixedLengthExpendedAndEncoded(20) + runDecodeTest(encoded, WithLength20(expected)) + } + + @Test + fun `should encode fixed-length list as element`() { + + @Serializable + data class TestData(@FixedLength(20) val list: List) + + val (expected, encoded) = fixedLengthExpendedAndEncoded(20) + runDecodeTest(encoded, TestData(expected)) + } + + private fun variableLengthExpendedAndEncoded(): Pair, ByteArray> { + val expected = listOf(true, false) + val encoded = useScaleWriter { + writeCompact(expected.size) + expected.forEach { boolean.write(this, it) } + } + + return expected to encoded + } + + private fun fixedLengthExpendedAndEncoded(size: Int): Pair, ByteArray> { + val expected = (0 until size).map { true } + val encoded = useScaleWriter { + expected.forEach { boolean.write(this, it) } + } + + return expected to encoded + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ObjectBinaryDecodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ObjectBinaryDecodeTest.kt new file mode 100644 index 00000000..14fcf3a9 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ObjectBinaryDecodeTest.kt @@ -0,0 +1,15 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode.binary + +import kotlinx.serialization.Serializable +import org.junit.Test + +class ObjectBinaryDecodeTest : BinaryDecodeTest() { + + @Serializable + object TestData + + @Test + fun `should decode value class`() { + runDecodeTest(raw = byteArrayOf(), expected = TestData) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/OptionalBinaryDecodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/OptionalBinaryDecodeTest.kt new file mode 100644 index 00000000..8afebd09 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/OptionalBinaryDecodeTest.kt @@ -0,0 +1,39 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode.binary + +import kotlinx.serialization.Serializable +import org.junit.Test + +class OptionalBinaryDecodeTest : BinaryDecodeTest() { + + @Test + fun `should decode optional value as root`() { + runDecodeTest(raw = byteArrayOf(0x00), expected = null) + runDecodeTest(raw = byteArrayOf(0x01, 0x12), expected = 0x12) + } + + @Test + fun `should decode optional value as element`() { + @Serializable + data class TestData(val a: Byte?) + + runDecodeTest(raw = byteArrayOf(0x00), expected = TestData(null)) + runDecodeTest(raw = byteArrayOf(0x01, 0x12), expected = TestData(0x12)) + } + + @Test + fun `should decode optional boolean value`() { + runDecodeTest(raw = byteArrayOf(0x00), expected = null) + runDecodeTest(raw = byteArrayOf(0x01), expected = false) + runDecodeTest(raw = byteArrayOf(0x02), expected = true) + } + + @Test + fun `should decode optional boolean as element`() { + @Serializable + data class TestData(val a: Boolean?) + + runDecodeTest(raw = byteArrayOf(0x00), expected = TestData(null)) + runDecodeTest(raw = byteArrayOf(0x01), expected = TestData(false)) + runDecodeTest(raw = byteArrayOf(0x02), expected = TestData(true)) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/PrimitivesBinaryDecodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/PrimitivesBinaryDecodeTest.kt new file mode 100644 index 00000000..1b82b5b4 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/PrimitivesBinaryDecodeTest.kt @@ -0,0 +1,120 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.BinaryScale +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.decodeFromByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.u8 +import io.novasama.substrate_sdk_android.runtime.definitions.types.useScaleWriter +import io.novasama.substrate_sdk_android.scale.dataType.list +import io.novasama.substrate_sdk_android.scale.dataType.toByteArray +import io.novasama.substrate_sdk_android.scale.dataType.uint16 +import io.novasama.substrate_sdk_android.scale.dataType.uint32 +import io.novasama.substrate_sdk_android.scale.dataType.uint64 +import io.novasama.substrate_sdk_android.scale.dataType.uint8 +import kotlinx.serialization.Serializable +import org.junit.Assert +import org.junit.Test + +class PrimitivesBinaryDecodeTest : BinaryDecodeTest() { + + @Test + fun `should decode boolean as element`() { + @Serializable + data class TestData(val a: Boolean) + + runDecodeTest(raw = byteArrayOf(0x01), expected = TestData(true)) + runDecodeTest(raw = byteArrayOf(0x00), expected = TestData(false)) + } + + @Test + fun `should decode boolean as root`() { + runDecodeTest(raw = byteArrayOf(0x01), expected = true) + runDecodeTest(raw = byteArrayOf(0x00), expected = false) + } + + @Test + fun `should decode byte`() { + runDecodeTest(raw = byteArrayOf(0x12), expected = 0x12.toByte()) + } + + @Test + fun `should decode long`() { + val expected: Long = 123 + val encoded = useScaleWriter { writeLong(expected) } + runDecodeTest(raw = encoded, expected = expected) + } + + @Test + fun `should decode short`() { + val expected: Short = 123 + val encoded = useScaleWriter { writeShort(expected) } + runDecodeTest(raw = encoded, expected = expected) + } + + @Test + fun `should decode int`() { + val expected = 123 + val encoded = useScaleWriter { writeUint32(expected) } + runDecodeTest(raw = encoded, expected = expected) + } + + @Test + fun `should decode u8`() { + listOf(UByte.MIN_VALUE, (-1).toUByte(), 0.toUByte(), 1.toUByte(), UByte.MAX_VALUE).forEach { + val raw = uint8.toByteArray(it) + runDecodeTest(raw = raw, expected = it) + } + } + + @Test + fun `should decode u16`() { + listOf(UShort.MIN_VALUE, (-1).toUShort(), 0.toUShort(), 1.toUShort(), UShort.MAX_VALUE).forEach { + val raw = uint16.toByteArray(it.toInt()) + val result = BinaryScale.decodeFromByteArray(raw) + Assert.assertEquals(it, result) + } + } + + @Test + fun `should decode u32`() { + listOf(UInt.MIN_VALUE, (-1).toUInt(), 0.toUInt(), 1.toUInt(), UInt.MAX_VALUE).forEach { + val raw = uint32.toByteArray(it) + runDecodeTest(raw = raw, expected = it) + } + } + + @Test + fun `should decode u64`() { + listOf(ULong.MIN_VALUE, (-1).toULong(), 0.toULong(), 1.toULong(), ULong.MAX_VALUE).forEach { + val raw = uint64.toByteArray(it.toString().toBigInteger()) + val result = BinaryScale.decodeFromByteArray(raw) + Assert.assertEquals(it, result) + } + } + + @Test + fun `should decode numbers as element`() { + @Serializable + data class TestData( + val s1: Byte, val s2: Short, val s3: Int, val s4: Long, + val u1: UByte, val u2: UShort, val u3: UInt, val u4: ULong, + ) + + val encoded = useScaleWriter { + writeByte(1) + writeShort(2) + writeUint32(3) + writeLong(4) + + writeByte(5) + writeUint16(6) + writeUint32(7) + uint64.write(this, 8.toBigInteger()) + } + val expected = TestData( + 1, 2, 3, 4, + 5.toUByte(), 6.toUShort(), 7.toUInt(), 8.toULong() + ) + + runDecodeTest(encoded, expected) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ValueClassBinaryDecodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ValueClassBinaryDecodeTest.kt new file mode 100644 index 00000000..51d52b09 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/decode/binary/ValueClassBinaryDecodeTest.kt @@ -0,0 +1,16 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.decode.binary + +import kotlinx.serialization.Serializable +import org.junit.Test + +class ValueClassBinaryDecodeTest : BinaryDecodeTest() { + + @JvmInline + @Serializable + value class TestData(val a: Byte) + + @Test + fun `should decode value class`() { + runDecodeTest(raw = byteArrayOf(0x12), expected = TestData(0x12)) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/BinaryEncodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/BinaryEncodeTest.kt new file mode 100644 index 00000000..7e723287 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/BinaryEncodeTest.kt @@ -0,0 +1,14 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.BinaryScale +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.encodeToByteArray +import org.junit.Assert + +open class BinaryEncodeTest { + + inline fun runEncodeTest(value: T, expected: ByteArray) { + val result: ByteArray = BinaryScale.encodeToByteArray(value) + + Assert.assertArrayEquals(expected, result) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ByteArrayBinaryEncodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ByteArrayBinaryEncodeTest.kt new file mode 100644 index 00000000..c25e7eff --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ByteArrayBinaryEncodeTest.kt @@ -0,0 +1,63 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.BinaryScale +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.FixedLength +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.WithLength20 +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.encodeToByteArray +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializers.ByteArraySerializable +import io.novasama.substrate_sdk_android.scale.dataType.byteArray +import io.novasama.substrate_sdk_android.scale.dataType.string +import io.novasama.substrate_sdk_android.scale.dataType.toByteArray +import kotlinx.serialization.Serializable +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class ByteArrayBinaryEncodeTest : BinaryEncodeTest() { + + @Test + fun `should encode fixed byte array from annotation`() { + @Serializable + class TestData(@FixedLength(20) val bytes: ByteArray) + + val value = ByteArray(20) { it.toByte() } + val result = BinaryScale.encodeToByteArray(TestData(value)) + assertArrayEquals(value, result) + } + + @Test + fun `should encode variable length byte array`() { + val value = ByteArray(25) { it.toByte() } + val expected = byteArray.toByteArray(value) + val result = BinaryScale.encodeToByteArray(value) + assertArrayEquals(expected, result) + } + + @JvmInline + @Serializable + value class ByteArraySerializableTestData(val a: ByteArraySerializable) + + @Test + fun `should keep compatibility with ByteArraySerializable`() { + val value = ByteArray(25) { it.toByte() } + val expected = byteArray.toByteArray(value) + + val result = BinaryScale.encodeToByteArray(ByteArraySerializableTestData(value)) + assertArrayEquals(expected, result) + } + + @Test + fun `should encode string`() { + val value = "Test" + val expected = string.toByteArray(value) + + runEncodeTest(value = value, expected = expected) + } + + @Test + fun `WithFixedLength wrapper works`() { + val bytes = ByteArray(20) { 1 } + val result = BinaryScale.encodeToByteArray>(WithLength20(bytes)) + + assertArrayEquals(bytes, result) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/CompactBinaryEncodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/CompactBinaryEncodeTest.kt new file mode 100644 index 00000000..55a53a9b --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/CompactBinaryEncodeTest.kt @@ -0,0 +1,30 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.serializers.BigIntegerSerializable +import io.novasama.substrate_sdk_android.scale.dataType.compactInt +import io.novasama.substrate_sdk_android.scale.dataType.toByteArray +import kotlinx.serialization.Serializable +import org.junit.Test +import java.math.BigInteger + +class CompactBinaryEncodeTest : BinaryEncodeTest() { + + @Test + fun `should encode compact`() { + val number = 100.toBigInteger() + runEncodeTest(number, encodedOf(number)) + } + + @Test + fun `should encode compact as element`() { + @Serializable + data class TestData(val a: BigIntegerSerializable) + + val number = 100.toBigInteger() + runEncodeTest(TestData(number), encodedOf(number)) + } + + private fun encodedOf(compact: BigInteger): ByteArray { + return compactInt.toByteArray(compact) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/DictEnumBinaryEncodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/DictEnumBinaryEncodeTest.kt new file mode 100644 index 00000000..97ca99d0 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/DictEnumBinaryEncodeTest.kt @@ -0,0 +1,42 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.EnumIndex +import kotlinx.serialization.Serializable +import org.junit.Test + +class DictEnumBinaryEncodeTest : BinaryEncodeTest() { + + @Serializable + sealed class Sealed { + + @Serializable + @EnumIndex(0) + object Null : Sealed() + + @Serializable + @EnumIndex(1) + data class Single(val element: Boolean) : Sealed() + + @Serializable + @EnumIndex(2) + data class Double(val a: Boolean, val b: Boolean) : Sealed() + } + + @Test + fun `should encode variant object`() = runEncodeTest( + value = Sealed.Null as Sealed, + expected = byteArrayOf(0x00) + ) + + @Test + fun `should encode variant value`() = runEncodeTest( + value = Sealed.Single(true) as Sealed, + expected = byteArrayOf(0x01, 0x01) + ) + + @Test + fun `should encode variant struct`() = runEncodeTest( + value = Sealed.Double(a = true, b = false) as Sealed, + expected = byteArrayOf(0x02, 0x01, 0x00) + ) +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/EnumBinaryEncodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/EnumBinaryEncodeTest.kt new file mode 100644 index 00000000..aece19a1 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/EnumBinaryEncodeTest.kt @@ -0,0 +1,48 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.EnumIndex +import kotlinx.serialization.Serializable +import org.junit.Test + +class EnumBinaryEncodeTest : BinaryEncodeTest() { + + @Serializable + enum class TestData { + A, B, C, D + } + + @Test + fun `should encode enum entry`() { + runEncodeTest(TestData.A, byteArrayOf(0x00)) + runEncodeTest(TestData.B, byteArrayOf(0x01)) + runEncodeTest(TestData.C, byteArrayOf(0x02)) + } + + @Serializable + enum class TestDataEnumIndex { + @EnumIndex(2) + A + } + + @Test + fun `should encode enum entry with custom index`() { + runEncodeTest(TestDataEnumIndex.A, byteArrayOf(0x02)) + } + + @Serializable + enum class TestDataMismatchingIndices { + @EnumIndex(2) + A, + @EnumIndex(1) + B, + @EnumIndex(0) + C + } + + @Test + fun `should encode enum entry with mismatching indices`() { + runEncodeTest(TestDataMismatchingIndices.A, byteArrayOf(0x02)) + runEncodeTest(TestDataMismatchingIndices.B, byteArrayOf(0x01)) + runEncodeTest(TestDataMismatchingIndices.C, byteArrayOf(0x00)) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ListEncodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ListEncodeTest.kt new file mode 100644 index 00000000..8e6bfdd0 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ListEncodeTest.kt @@ -0,0 +1,61 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.FixedLength +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.annotations.WithLength20 +import io.novasama.substrate_sdk_android.runtime.definitions.types.useScaleWriter +import io.novasama.substrate_sdk_android.scale.dataType.boolean +import kotlinx.serialization.Serializable +import org.junit.Test + +class ListEncodeTest : BinaryEncodeTest() { + + @Test + fun `should encode variable-length list`() { + val (value, expected) = variableLengthValueAndEncoded() + runEncodeTest(value, expected) + } + + @Test + fun `should encode variable-length list as element`() { + @Serializable + data class TestData(val list: List) + + val (value, expected) = variableLengthValueAndEncoded() + runEncodeTest(TestData(value), expected) + } + + @Test + fun `should encode fixed-length list`() { + val (value, expected) = fixedLengthValueAndEncoded(20) + runEncodeTest(WithLength20(value), expected) + } + + @Test + fun `should encode fixed-length list as element`() { + + @Serializable + data class TestData(@FixedLength(20) val list: List) + + val (value, expected) = fixedLengthValueAndEncoded(20) + runEncodeTest(TestData(value), expected) + } + + private fun variableLengthValueAndEncoded(): Pair, ByteArray> { + val value = listOf(true, false) + val encoded = useScaleWriter { + writeCompact(value.size) + value.forEach { boolean.write(this, it) } + } + + return value to encoded + } + + private fun fixedLengthValueAndEncoded(size: Int): Pair, ByteArray> { + val value = (0 until size).map { true } + val encoded = useScaleWriter { + value.forEach { boolean.write(this, it) } + } + + return value to encoded + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ObjectBinaryEncodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ObjectBinaryEncodeTest.kt new file mode 100644 index 00000000..4388de15 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ObjectBinaryEncodeTest.kt @@ -0,0 +1,15 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode.binary + +import kotlinx.serialization.Serializable +import org.junit.Test + +class ObjectBinaryEncodeTest : BinaryEncodeTest() { + + @Serializable + object TestData + + @Test + fun `should encode object`() { + runEncodeTest(value = TestData, expected = byteArrayOf()) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/OptionalBinaryEncodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/OptionalBinaryEncodeTest.kt new file mode 100644 index 00000000..8b98a65a --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/OptionalBinaryEncodeTest.kt @@ -0,0 +1,39 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode.binary + +import kotlinx.serialization.Serializable +import org.junit.Test + +class OptionalBinaryEncodeTest : BinaryEncodeTest() { + + @Test + fun `should encode optional value as root`() { + runEncodeTest(value = null, expected = byteArrayOf(0x00)) + runEncodeTest(value = 0x12, expected = byteArrayOf(0x01, 0x12)) + } + + @Test + fun `should encode optional value as element`() { + @Serializable + data class TestData(val a: Byte?) + + runEncodeTest(value = TestData(null), expected = byteArrayOf(0x00)) + runEncodeTest(value = TestData(0x12), expected = byteArrayOf(0x01, 0x12)) + } + + @Test + fun `should encode optional boolean value`() { + runEncodeTest(value = null, expected = byteArrayOf(0x00)) + runEncodeTest(value = false, expected = byteArrayOf(0x01)) + runEncodeTest(value = true, expected = byteArrayOf(0x02)) + } + + @Test + fun `should encode optional boolean as element`() { + @Serializable + data class TestData(val a: Boolean?) + +// runEncodeTest(value = TestData(null), expected = byteArrayOf(0x00)) + runEncodeTest(value = TestData(false), expected = byteArrayOf(0x01)) +// runEncodeTest(value = TestData(true), expected = byteArrayOf(0x02)) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/PrimitivesBinaryEncodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/PrimitivesBinaryEncodeTest.kt new file mode 100644 index 00000000..c8237e0f --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/PrimitivesBinaryEncodeTest.kt @@ -0,0 +1,118 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode.binary + +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.BinaryScale +import io.novasama.substrate_sdk_android.koltinx_serialization_scale.binary.encodeToByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.useScaleWriter +import io.novasama.substrate_sdk_android.scale.dataType.uint16 +import io.novasama.substrate_sdk_android.scale.dataType.uint32 +import io.novasama.substrate_sdk_android.scale.dataType.uint64 +import io.novasama.substrate_sdk_android.scale.dataType.uint8 +import io.novasama.substrate_sdk_android.scale.dataType.toByteArray +import kotlinx.serialization.Serializable +import org.junit.Assert +import org.junit.Test + +class PrimitivesBinaryEncodeTest : BinaryEncodeTest() { + + @Test + fun `should encode boolean as element`() { + @Serializable + data class TestData(val a: Boolean) + + runEncodeTest(value = TestData(true), expected = byteArrayOf(0x01)) + runEncodeTest(value = TestData(false), expected = byteArrayOf(0x00)) + } + + @Test + fun `should encode boolean as root`() { + runEncodeTest(value = true, expected = byteArrayOf(0x01)) + runEncodeTest(value = false, expected = byteArrayOf(0x00)) + } + + @Test + fun `should encode byte`() { + runEncodeTest(value = 0x12.toByte(), expected = byteArrayOf(0x12)) + } + + @Test + fun `should encode long`() { + val value: Long = 123 + val expected = useScaleWriter { writeLong(value) } + runEncodeTest(value = value, expected = expected) + } + + @Test + fun `should encode short`() { + val value: Short = 123 + val expected = useScaleWriter { writeShort(value) } + runEncodeTest(value = value, expected = expected) + } + + @Test + fun `should encode int`() { + val value = 123 + val expected = useScaleWriter { writeUint32(value) } + runEncodeTest(value = value, expected = expected) + } + + @Test + fun `should encode u8`() { + listOf(UByte.MIN_VALUE, (-1).toUByte(), 0.toUByte(), 1.toUByte(), UByte.MAX_VALUE).forEach { + val expected = uint8.toByteArray(it) + runEncodeTest(value = it, expected = expected) + } + } + + @Test + fun `should encode u16`() { + listOf(UShort.MIN_VALUE, (-1).toUShort(), 0.toUShort(), 1.toUShort(), UShort.MAX_VALUE).forEach { + val expected = uint16.toByteArray(it.toInt()) + val result = BinaryScale.encodeToByteArray(it) + Assert.assertArrayEquals(expected, result) + } + } + + @Test + fun `should encode u32`() { + listOf(UInt.MIN_VALUE, (-1).toUInt(), 0.toUInt(), 1.toUInt(), UInt.MAX_VALUE).forEach { + val expected = uint32.toByteArray(it) + runEncodeTest(value = it, expected = expected) + } + } + + @Test + fun `should encode u64`() { + listOf(ULong.MIN_VALUE, (-1).toULong(), 0.toULong(), 1.toULong(), ULong.MAX_VALUE).forEach { + val expected = uint64.toByteArray(it.toString().toBigInteger()) + val result = BinaryScale.encodeToByteArray(it) + Assert.assertArrayEquals(expected, result) + } + } + + @Test + fun `should encode numbers as element`() { + @Serializable + data class TestData( + val s1: Byte, val s2: Short, val s3: Int, val s4: Long, + val u1: UByte, val u2: UShort, val u3: UInt, val u4: ULong, + ) + + val value = TestData( + 1, 2, 3, 4, + 5.toUByte(), 6.toUShort(), 7.toUInt(), 8.toULong() + ) + val expected = useScaleWriter { + writeByte(1) + writeShort(2) + writeUint32(3) + writeLong(4) + + writeByte(5) + writeUint16(6) + writeUint32(7) + uint64.write(this, 8.toBigInteger()) + } + + runEncodeTest(value, expected) + } +} \ No newline at end of file diff --git a/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ValueClassBinaryEncodeTest.kt b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ValueClassBinaryEncodeTest.kt new file mode 100644 index 00000000..d5b81620 --- /dev/null +++ b/koltinx-serialization-scale/src/test/java/io/novasama/substrate_sdk_android/koltinx_serialization_scale/encode/binary/ValueClassBinaryEncodeTest.kt @@ -0,0 +1,16 @@ +package io.novasama.substrate_sdk_android.koltinx_serialization_scale.encode.binary + +import kotlinx.serialization.Serializable +import org.junit.Test + +class ValueClassBinaryEncodeTest : BinaryEncodeTest() { + + @JvmInline + @Serializable + value class TestData(val a: Byte) + + @Test + fun `should encode value class`() { + runEncodeTest(value = TestData(0x12), expected = byteArrayOf(0x12)) + } +} \ No newline at end of file diff --git a/substrate-sdk-android/build.gradle b/substrate-sdk-android/build.gradle index 29fb49eb..2c650829 100644 --- a/substrate-sdk-android/build.gradle +++ b/substrate-sdk-android/build.gradle @@ -90,7 +90,7 @@ tasks.configureEach { task -> } dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) + api fileTree(dir: 'libs', include: ['*.jar']) implementation bouncyCastleDep implementation ed25519Dep diff --git a/substrate-sdk-android/src/main/java/io/novasama/substrate_sdk_android/encrypt/seed/SeedCreator.kt b/substrate-sdk-android/src/main/java/io/novasama/substrate_sdk_android/encrypt/seed/SeedCreator.kt index 9eb5b829..eccb07f7 100644 --- a/substrate-sdk-android/src/main/java/io/novasama/substrate_sdk_android/encrypt/seed/SeedCreator.kt +++ b/substrate-sdk-android/src/main/java/io/novasama/substrate_sdk_android/encrypt/seed/SeedCreator.kt @@ -3,9 +3,9 @@ package io.novasama.substrate_sdk_android.encrypt.seed import org.bouncycastle.crypto.digests.SHA512Digest import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator import org.bouncycastle.crypto.params.KeyParameter +import java.security.SecureRandom import java.text.Normalizer import java.text.Normalizer.normalize -import java.security.SecureRandom object SeedCreator {