From 5c694c9fd0c8b89000bf18879a5ba09ba014ed92 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 30 Jun 2025 19:10:49 +0530 Subject: [PATCH 1/4] [ECO-5386] Added interfaces for liveobject serialization 1. Added LiveObjectSerializer interface consisting methods for json and msgpack serialization. 2. Added LiveObjectsHelper to initialize LiveObjectsPlugin and LiveObjectSerializer 3. Updated code to use LiveObjectSerializer and LiveObjectsHelper to serialize and initialize liveobjects --- .../lib/objects/LiveObjectSerializer.java | 51 +++++++++++++++++++ .../ably/lib/objects/LiveObjectsHelper.java | 43 ++++++++++++++++ .../objects/LiveObjectsJsonSerializer.java | 38 ++++++++++++++ .../io/ably/lib/realtime/AblyRealtime.java | 20 +------- .../io/ably/lib/types/ProtocolMessage.java | 32 ++++++++++++ .../io/ably/lib/types/ProtocolSerializer.java | 15 +++--- 6 files changed, 174 insertions(+), 25 deletions(-) create mode 100644 lib/src/main/java/io/ably/lib/objects/LiveObjectSerializer.java create mode 100644 lib/src/main/java/io/ably/lib/objects/LiveObjectsHelper.java create mode 100644 lib/src/main/java/io/ably/lib/objects/LiveObjectsJsonSerializer.java diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectSerializer.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectSerializer.java new file mode 100644 index 000000000..dcf0ce5cb --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectSerializer.java @@ -0,0 +1,51 @@ +package io.ably.lib.objects; + +import com.google.gson.JsonArray; +import org.jetbrains.annotations.NotNull; +import org.msgpack.core.MessagePacker; +import org.msgpack.core.MessageUnpacker; + +import java.io.IOException; + +/** + * Serializer interface for converting between LiveObject arrays and their + * MessagePack or JSON representations. + */ +public interface LiveObjectSerializer { + /** + * Reads a MessagePack array from the given unpacker and deserializes it into an Object array. + * + * @param unpacker the MessageUnpacker to read from + * @return the deserialized Object array + * @throws IOException if an I/O error occurs during unpacking + */ + @NotNull + Object[] readMsgpackArray(@NotNull MessageUnpacker unpacker) throws IOException; + + /** + * Serializes the given Object array as a MessagePack array using the provided packer. + * + * @param objects the Object array to serialize + * @param packer the MessagePacker to write to + * @throws IOException if an I/O error occurs during packing + */ + void writeMsgpackArray(@NotNull Object[] objects, @NotNull MessagePacker packer) throws IOException; + + /** + * Reads a JSON array from the given {@link JsonArray} and deserializes it into an Object array. + * + * @param json the {@link JsonArray} representing the array to deserialize + * @return the deserialized Object array + */ + @NotNull + Object[] readFromJsonArray(@NotNull JsonArray json); + + /** + * Serializes the given Object array as a JSON array. + * + * @param objects the Object array to serialize + * @return the resulting JsonArray + */ + @NotNull + JsonArray asJsonArray(@NotNull Object[] objects); +} diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectsHelper.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectsHelper.java new file mode 100644 index 000000000..78d6c35d3 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsHelper.java @@ -0,0 +1,43 @@ +package io.ably.lib.objects; + +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.util.Log; + +import java.lang.reflect.InvocationTargetException; + +public class LiveObjectsHelper { + + private static final String TAG = LiveObjectsHelper.class.getName(); + private static volatile LiveObjectSerializer liveObjectSerializer; + + public static LiveObjectsPlugin tryInitializeLiveObjectsPlugin(AblyRealtime ablyRealtime) { + try { + Class liveObjectsImplementation = Class.forName("io.ably.lib.objects.DefaultLiveObjectsPlugin"); + LiveObjectsAdapter adapter = new Adapter(ablyRealtime); + return (LiveObjectsPlugin) liveObjectsImplementation + .getDeclaredConstructor(LiveObjectsAdapter.class) + .newInstance(adapter); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | + InvocationTargetException e) { + Log.i(TAG, "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e); + return null; + } + } + + public static LiveObjectSerializer getLiveObjectSerializer() { + if (liveObjectSerializer == null) { + synchronized (LiveObjectsHelper.class) { + try { + Class serializerClass = Class.forName("io.ably.lib.objects.serialization.DefaultLiveObjectSerializer"); + liveObjectSerializer = (LiveObjectSerializer) serializerClass.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | + NoSuchMethodException | + InvocationTargetException e) { + Log.e(TAG, "Failed to init LiveObjectSerializer, LiveObjects plugin not included in the classpath", e); + return null; + } + } + } + return liveObjectSerializer; + } +} diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectsJsonSerializer.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectsJsonSerializer.java new file mode 100644 index 000000000..f6a843474 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsJsonSerializer.java @@ -0,0 +1,38 @@ +package io.ably.lib.objects; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import io.ably.lib.util.Log; + +import java.lang.reflect.Type; + +public class LiveObjectsJsonSerializer implements JsonSerializer, JsonDeserializer { + private static final String TAG = LiveObjectsJsonSerializer.class.getName(); + private final LiveObjectSerializer serializer = LiveObjectsHelper.getLiveObjectSerializer(); + + @Override + public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + if (serializer == null) { + Log.w(TAG, "Skipping 'state' field json deserialization because LiveObjectsSerializer not found."); + return null; + } + if (!json.isJsonArray()) { + throw new JsonParseException("Expected a JSON array for 'state' field, but got: " + json); + } + return serializer.readFromJsonArray(json.getAsJsonArray()); + } + + @Override + public JsonElement serialize(Object[] src, Type typeOfSrc, JsonSerializationContext context) { + if (serializer == null) { + Log.w(TAG, "Skipping 'state' field json serialization because LiveObjectsSerializer not found."); + return JsonNull.INSTANCE; + } + return serializer.asJsonArray(src); + } +} diff --git a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java index a933a7f62..8c0d9ee03 100644 --- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java +++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java @@ -1,13 +1,11 @@ package io.ably.lib.realtime; -import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import io.ably.lib.objects.Adapter; -import io.ably.lib.objects.LiveObjectsAdapter; +import io.ably.lib.objects.LiveObjectsHelper; import io.ably.lib.objects.LiveObjectsPlugin; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Auth; @@ -74,7 +72,7 @@ public AblyRealtime(ClientOptions options) throws AblyException { final InternalChannels channels = new InternalChannels(); this.channels = channels; - liveObjectsPlugin = tryInitializeLiveObjectsPlugin(); + liveObjectsPlugin = LiveObjectsHelper.tryInitializeLiveObjectsPlugin(this); connection = new Connection(this, channels, platformAgentProvider, liveObjectsPlugin); @@ -185,20 +183,6 @@ public interface Channels extends ReadOnlyMap { void release(String channelName); } - private LiveObjectsPlugin tryInitializeLiveObjectsPlugin() { - try { - Class liveObjectsImplementation = Class.forName("io.ably.lib.objects.DefaultLiveObjectsPlugin"); - LiveObjectsAdapter adapter = new Adapter(this); - return (LiveObjectsPlugin) liveObjectsImplementation - .getDeclaredConstructor(LiveObjectsAdapter.class) - .newInstance(adapter); - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | - InvocationTargetException e) { - Log.i(TAG, "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e); - return null; - } - } - private class InternalChannels extends InternalMap implements Channels, ConnectionManager.Channels { /** * Get the named channel; if it does not already exist, diff --git a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java index 73db3bf23..efcd32519 100644 --- a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java +++ b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java @@ -4,6 +4,11 @@ import java.lang.reflect.Type; import java.util.Map; +import com.google.gson.annotations.JsonAdapter; +import io.ably.lib.objects.LiveObjectSerializer; +import io.ably.lib.objects.LiveObjectsHelper; +import io.ably.lib.objects.LiveObjectsJsonSerializer; +import org.jetbrains.annotations.Nullable; import org.msgpack.core.MessageFormat; import org.msgpack.core.MessagePacker; import org.msgpack.core.MessageUnpacker; @@ -123,6 +128,14 @@ public ProtocolMessage(Action action, String channel) { public AuthDetails auth; public Map params; public Annotation[] annotations; + /** + * This will be null if we skipped decoding this property due to user not requesting Objects functionality + * JsonAdapter annotation supports java version (1.8) mentioned in build.gradle + * This is targeted and specific to the state field, so won't affect other fields + */ + @Nullable + @JsonAdapter(LiveObjectsJsonSerializer.class) + public Object[] state; public boolean hasFlag(final Flag flag) { return (flags & flag.getMask()) == flag.getMask(); @@ -147,6 +160,7 @@ void writeMsgpack(MessagePacker packer) throws IOException { if(params != null) ++fieldCount; if(channelSerial != null) ++fieldCount; if(annotations != null) ++fieldCount; + if(state != null && LiveObjectsHelper.getLiveObjectSerializer() != null) ++fieldCount; packer.packMapHeader(fieldCount); packer.packString("action"); packer.packInt(action.getValue()); @@ -186,6 +200,15 @@ void writeMsgpack(MessagePacker packer) throws IOException { packer.packString("annotations"); AnnotationSerializer.writeMsgpackArray(annotations, packer); } + if(state != null) { + LiveObjectSerializer liveObjectsSerializer = LiveObjectsHelper.getLiveObjectSerializer(); + if (liveObjectsSerializer != null) { + packer.packString("state"); + liveObjectsSerializer.writeMsgpackArray(state, packer); + } else { + Log.w(TAG, "Skipping 'state' field msgpack serialization because LiveObjectsSerializer not found"); + } + } } ProtocolMessage readMsgpack(MessageUnpacker unpacker) throws IOException { @@ -248,6 +271,15 @@ ProtocolMessage readMsgpack(MessageUnpacker unpacker) throws IOException { case "annotations": annotations = AnnotationSerializer.readMsgpackArray(unpacker); break; + case "state": + LiveObjectSerializer liveObjectsSerializer = LiveObjectsHelper.getLiveObjectSerializer(); + if (liveObjectsSerializer != null) { + state = liveObjectsSerializer.readMsgpackArray(unpacker); + } else { + Log.w(TAG, "Skipping 'state' field msgpack deserialization because LiveObjectsSerializer not found"); + unpacker.skipValue(); + } + break; default: Log.v(TAG, "Unexpected field: " + fieldName); unpacker.skipValue(); diff --git a/lib/src/main/java/io/ably/lib/types/ProtocolSerializer.java b/lib/src/main/java/io/ably/lib/types/ProtocolSerializer.java index 97e5fc80b..e33bfe186 100644 --- a/lib/src/main/java/io/ably/lib/types/ProtocolSerializer.java +++ b/lib/src/main/java/io/ably/lib/types/ProtocolSerializer.java @@ -14,7 +14,7 @@ public class ProtocolSerializer { /**************************************** * Msgpack decode ****************************************/ - + public static ProtocolMessage readMsgpack(byte[] packed) throws AblyException { try { MessageUnpacker unpacker = Serialisation.msgpackUnpackerConfig.newUnpacker(packed); @@ -27,22 +27,23 @@ public static ProtocolMessage readMsgpack(byte[] packed) throws AblyException { /**************************************** * Msgpack encode ****************************************/ - - public static byte[] writeMsgpack(ProtocolMessage message) { + + public static byte[] writeMsgpack(ProtocolMessage message) throws AblyException { ByteArrayOutputStream out = new ByteArrayOutputStream(); MessagePacker packer = Serialisation.msgpackPackerConfig.newPacker(out); try { message.writeMsgpack(packer); - packer.flush(); return out.toByteArray(); - } catch(IOException e) { return null; } + } catch (IOException ioe) { + throw AblyException.fromThrowable(ioe); + } } /**************************************** * JSON decode ****************************************/ - + public static ProtocolMessage fromJSON(String packed) throws AblyException { return Serialisation.gson.fromJson(packed, ProtocolMessage.class); } @@ -50,7 +51,7 @@ public static ProtocolMessage fromJSON(String packed) throws AblyException { /**************************************** * JSON encode ****************************************/ - + public static byte[] writeJSON(ProtocolMessage message) throws AblyException { return Serialisation.gson.toJson(message).getBytes(Charset.forName("UTF-8")); } From 53116e8fa4df3ea2eaf6b2430bf081a919ea03ef Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 30 Jun 2025 19:14:31 +0530 Subject: [PATCH 2/4] [ECO-5386] Added impl. for LiveObjectSerializer interface 1. Implemented JsonSerializetion using gson library 2. Implemented MsgpackSerialization without external dependency 3. Annotated/updated ObjectMessage fields for gson serialization 4. Added msgpack dependency to liveobjects --- live-objects/build.gradle.kts | 1 + .../kotlin/io/ably/lib/objects/Helpers.kt | 8 +- .../io/ably/lib/objects/ObjectMessage.kt | 18 +- .../io/ably/lib/objects/Serialization.kt | 10 - .../serialization/DefaultSerialization.kt | 46 ++ .../serialization/JsonSerialization.kt | 99 +++ .../serialization/MsgpackSerialization.kt | 723 ++++++++++++++++++ 7 files changed, 884 insertions(+), 21 deletions(-) delete mode 100644 live-objects/src/main/kotlin/io/ably/lib/objects/Serialization.kt create mode 100644 live-objects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt create mode 100644 live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt create mode 100644 live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt diff --git a/live-objects/build.gradle.kts b/live-objects/build.gradle.kts index 2adb88fff..ce6642496 100644 --- a/live-objects/build.gradle.kts +++ b/live-objects/build.gradle.kts @@ -11,6 +11,7 @@ repositories { dependencies { implementation(project(":java")) + implementation(libs.bundles.common) implementation(libs.coroutine.core) testImplementation(kotlin("test")) diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt index 3da94183b..be6373eae 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt @@ -39,18 +39,18 @@ internal enum class ProtocolMessageFormat(private val value: String) { override fun toString(): String = value } -internal class Binary(val data: ByteArray?) { +internal class Binary(val data: ByteArray) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Binary) return false - return data?.contentEquals(other.data) == true + return data.contentEquals(other.data) } override fun hashCode(): Int { - return data?.contentHashCode() ?: 0 + return data.contentHashCode() } } internal fun Binary.size(): Int { - return data?.size ?: 0 + return data.size } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt index be168f993..47c328273 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt @@ -3,6 +3,12 @@ package io.ably.lib.objects import com.google.gson.JsonArray import com.google.gson.JsonObject +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import io.ably.lib.objects.serialization.InitialValueJsonSerializer +import io.ably.lib.objects.serialization.ObjectDataJsonSerializer +import io.ably.lib.objects.serialization.gson + /** * An enum class representing the different actions that can be performed on an object. * Spec: OOP2 @@ -28,6 +34,7 @@ internal enum class MapSemantics(val code: Int) { * An ObjectData represents a value in an object on a channel. * Spec: OD1 */ +@JsonAdapter(ObjectDataJsonSerializer::class) internal data class ObjectData( /** * A reference to another object, used to support composable object structures. @@ -35,12 +42,6 @@ internal data class ObjectData( */ val objectId: String? = null, - /** - * Can be set by the client to indicate that value in `string` or `bytes` field have an encoding. - * Spec: OD2b - */ - val encoding: String? = null, - /** * String, number, boolean or binary - a concrete value of the object * Spec: OD2c @@ -217,11 +218,13 @@ internal data class ObjectOperation( * the initialValue, nonce, and initialValueEncoding will be removed. * Spec: OOP3h */ + @JsonAdapter(InitialValueJsonSerializer::class) val initialValue: Binary? = null, /** The initial value encoding defines how the initialValue should be interpreted. * Spec: OOP3i */ + @Deprecated("Will be removed in the future, initialValue will be json string") val initialValueEncoding: ProtocolMessageFormat? = null ) @@ -312,7 +315,7 @@ internal data class ObjectMessage( * or validation of the @extras@ field itself, but should treat it opaquely, encoding it and passing it to realtime unaltered * Spec: OM2d */ - val extras: Any? = null, + val extras: JsonObject? = null, /** * Describes an operation to be applied to an object. @@ -328,6 +331,7 @@ internal data class ObjectMessage( * the `ProtocolMessage` encapsulating it is `OBJECT_SYNC`. * Spec: OM2g */ + @SerializedName("object") val objectState: ObjectState? = null, /** diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/Serialization.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Serialization.kt deleted file mode 100644 index e2279d843..000000000 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/Serialization.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.ably.lib.objects - -import com.google.gson.Gson -import com.google.gson.GsonBuilder - -internal val gson: Gson = createGsonSerializer() - -private fun createGsonSerializer(): Gson { - return GsonBuilder().create() // Do not call serializeNulls() to omit null values -} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt new file mode 100644 index 000000000..a712b3c7e --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt @@ -0,0 +1,46 @@ +@file:Suppress("UNCHECKED_CAST") + +package io.ably.lib.objects.serialization + +import com.google.gson.* +import io.ably.lib.objects.* + +import io.ably.lib.objects.ObjectMessage +import org.msgpack.core.MessagePacker +import org.msgpack.core.MessageUnpacker + +/** + * Default implementation of {@link LiveObjectSerializer} that handles serialization/deserialization + * of ObjectMessage arrays for both JSON and MessagePack formats using Jackson and Gson. + * Dynamically loaded by LiveObjectsHelper#getLiveObjectSerializer() to avoid hard dependencies. + */ +@Suppress("unused") // Used via reflection in LiveObjectsHelper +internal class DefaultLiveObjectSerializer : LiveObjectSerializer { + + override fun readMsgpackArray(unpacker: MessageUnpacker): Array { + val objectMessagesCount = unpacker.unpackArrayHeader() + return Array(objectMessagesCount) { readObjectMessage(unpacker) } + } + + override fun writeMsgpackArray(objects: Array, packer: MessagePacker) { + val objectMessages: Array = objects as Array + packer.packArrayHeader(objectMessages.size) + objectMessages.forEach { it.writeMsgpack(packer) } + } + + override fun readFromJsonArray(json: JsonArray): Array { + return json.map { element -> + if (element.isJsonObject) element.asJsonObject.toObjectMessage() + else throw JsonParseException("Expected JsonObject, but found: $element") + }.toTypedArray() + } + + override fun asJsonArray(objects: Array): JsonArray { + val objectMessages: Array = objects as Array + val jsonArray = JsonArray() + for (objectMessage in objectMessages) { + jsonArray.add(objectMessage.toJsonObject()) + } + return jsonArray + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt new file mode 100644 index 000000000..c60cbee9c --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt @@ -0,0 +1,99 @@ +package io.ably.lib.objects.serialization + +import com.google.gson.* +import io.ably.lib.objects.Binary +import io.ably.lib.objects.MapSemantics +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectValue +import java.lang.reflect.Type +import java.util.* +import kotlin.enums.EnumEntries + +// Gson instance for JSON serialization/deserialization +internal val gson = GsonBuilder() + .registerTypeAdapter(ObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, ObjectOperationAction.entries)) + .registerTypeAdapter(MapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, MapSemantics.entries)) + .create() + +internal fun ObjectMessage.toJsonObject(): JsonObject { + return gson.toJsonTree(this).asJsonObject +} + +internal fun JsonObject.toObjectMessage(): ObjectMessage { + return gson.fromJson(this, ObjectMessage::class.java) +} + +internal class EnumCodeTypeAdapter>( + private val getCode: (T) -> Int, + private val enumValues: EnumEntries +) : JsonSerializer, JsonDeserializer { + + override fun serialize(src: T, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { + return JsonPrimitive(getCode(src)) + } + + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): T { + val code = json.asInt + return enumValues.first { getCode(it) == code } + } +} + +internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeserializer { + override fun serialize(src: ObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { + val obj = JsonObject() + src.objectId?.let { obj.addProperty("objectId", it) } + + src.value?.let { value -> + when (val v = value.value) { + is Boolean -> obj.addProperty("boolean", v) + is String -> obj.addProperty("string", v) + is Number -> obj.addProperty("number", v.toDouble()) + is Binary -> obj.addProperty("bytes", Base64.getEncoder().encodeToString(v.data)) + // Spec: OD4c5 + is JsonObject, is JsonArray -> { + obj.addProperty("string", v.toString()) + obj.addProperty("encoding", "json") + } + } + } + return obj + } + + override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): ObjectData { + val obj = if (json.isJsonObject) json.asJsonObject else throw JsonParseException("Expected JsonObject") + val objectId = if (obj.has("objectId")) obj.get("objectId").asString else null + val encoding = if (obj.has("encoding")) obj.get("encoding").asString else null + val value = when { + obj.has("boolean") -> ObjectValue(obj.get("boolean").asBoolean) + // Spec: OD5b3 + obj.has("string") && encoding == "json" -> { + val jsonStr = obj.get("string").asString + val parsed = JsonParser.parseString(jsonStr) + ObjectValue( + when { + parsed.isJsonObject -> parsed.asJsonObject + parsed.isJsonArray -> parsed.asJsonArray + else -> throw JsonParseException("Invalid JSON string for encoding=json") + } + ) + } + obj.has("string") -> ObjectValue(obj.get("string").asString) + obj.has("number") -> ObjectValue(obj.get("number").asDouble) + obj.has("bytes") -> ObjectValue(Binary(Base64.getDecoder().decode(obj.get("bytes").asString))) + else -> throw JsonParseException("ObjectData must have one of the fields: boolean, string, number, or bytes") + } + return ObjectData(objectId, value) + } +} + +internal class InitialValueJsonSerializer : JsonSerializer, JsonDeserializer { + override fun serialize(src: Binary, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { + return JsonPrimitive(Base64.getEncoder().encodeToString(src.data)) + } + + override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): Binary { + return Binary(Base64.getDecoder().decode(json.asString)) + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt new file mode 100644 index 000000000..73bb29a31 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt @@ -0,0 +1,723 @@ +package io.ably.lib.objects.serialization + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.ably.lib.objects.Binary +import io.ably.lib.objects.MapSemantics +import io.ably.lib.objects.ObjectCounter +import io.ably.lib.objects.ObjectCounterOp +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectMap +import io.ably.lib.objects.ObjectMapEntry +import io.ably.lib.objects.ObjectMapOp +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectOperation +import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectValue +import io.ably.lib.objects.ProtocolMessageFormat +import io.ably.lib.util.Serialisation +import org.msgpack.core.MessageFormat +import org.msgpack.core.MessagePacker +import org.msgpack.core.MessageUnpacker + +/** + * Write ObjectMessage to MessagePacker + */ +internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (id != null) fieldCount++ + if (timestamp != null) fieldCount++ + if (clientId != null) fieldCount++ + if (connectionId != null) fieldCount++ + if (extras != null) fieldCount++ + if (operation != null) fieldCount++ + if (objectState != null) fieldCount++ + if (serial != null) fieldCount++ + if (siteCode != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (id != null) { + packer.packString("id") + packer.packString(id) + } + + if (timestamp != null) { + packer.packString("timestamp") + packer.packLong(timestamp) + } + + if (clientId != null) { + packer.packString("clientId") + packer.packString(clientId) + } + + if (connectionId != null) { + packer.packString("connectionId") + packer.packString(connectionId) + } + + if (extras != null) { + packer.packString("extras") + packer.writePayload(Serialisation.gsonToMsgpack(extras)) + } + + if (operation != null) { + packer.packString("operation") + operation.writeMsgpack(packer) + } + + if (objectState != null) { + packer.packString("object") + objectState.writeMsgpack(packer) + } + + if (serial != null) { + packer.packString("serial") + packer.packString(serial) + } + + if (siteCode != null) { + packer.packString("siteCode") + packer.packString(siteCode) + } +} + +/** + * Read an ObjectMessage from MessageUnpacker + */ +internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { + if (unpacker.nextFormat == MessageFormat.NIL) { + unpacker.unpackNil() + return ObjectMessage() // default/empty message + } + + val fieldCount = unpacker.unpackMapHeader() + + var id: String? = null + var timestamp: Long? = null + var clientId: String? = null + var connectionId: String? = null + var extras: JsonObject? = null + var operation: ObjectOperation? = null + var objectState: ObjectState? = null + var serial: String? = null + var siteCode: String? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "id" -> id = unpacker.unpackString() + "timestamp" -> timestamp = unpacker.unpackLong() + "clientId" -> clientId = unpacker.unpackString() + "connectionId" -> connectionId = unpacker.unpackString() + "extras" -> extras = Serialisation.msgpackToGson(unpacker.unpackValue()) as? JsonObject + "operation" -> operation = readObjectOperation(unpacker) + "object" -> objectState = readObjectState(unpacker) + "serial" -> serial = unpacker.unpackString() + "siteCode" -> siteCode = unpacker.unpackString() + else -> unpacker.skipValue() + } + } + + return ObjectMessage( + id = id, + timestamp = timestamp, + clientId = clientId, + connectionId = connectionId, + extras = extras, + operation = operation, + objectState = objectState, + serial = serial, + siteCode = siteCode + ) +} + +/** + * Write ObjectOperation to MessagePacker + */ +private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { + var fieldCount = 1 // action is always required + + if (objectId.isNotEmpty()) fieldCount++ + if (mapOp != null) fieldCount++ + if (counterOp != null) fieldCount++ + if (map != null) fieldCount++ + if (counter != null) fieldCount++ + if (nonce != null) fieldCount++ + if (initialValue != null) fieldCount++ + if (initialValueEncoding != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + packer.packString("action") + packer.packInt(action.code) + + if (objectId.isNotEmpty()) { + packer.packString("objectId") + packer.packString(objectId) + } + + if (mapOp != null) { + packer.packString("mapOp") + mapOp.writeMsgpack(packer) + } + + if (counterOp != null) { + packer.packString("counterOp") + counterOp.writeMsgpack(packer) + } + + if (map != null) { + packer.packString("map") + map.writeMsgpack(packer) + } + + if (counter != null) { + packer.packString("counter") + counter.writeMsgpack(packer) + } + + if (nonce != null) { + packer.packString("nonce") + packer.packString(nonce) + } + + if (initialValue != null) { + packer.packString("initialValue") + packer.packBinaryHeader(initialValue.data.size) + packer.writePayload(initialValue.data) + } + + if (initialValueEncoding != null) { + packer.packString("initialValueEncoding") + packer.packString(initialValueEncoding.name) + } +} + +/** + * Read ObjectOperation from MessageUnpacker + */ +private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { + val fieldCount = unpacker.unpackMapHeader() + + var action: ObjectOperationAction? = null + var objectId: String = "" + var mapOp: ObjectMapOp? = null + var counterOp: ObjectCounterOp? = null + var map: ObjectMap? = null + var counter: ObjectCounter? = null + var nonce: String? = null + var initialValue: Binary? = null + var initialValueEncoding: ProtocolMessageFormat? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "action" -> { + val actionCode = unpacker.unpackInt() + action = ObjectOperationAction.entries.find { it.code == actionCode } + ?: throw IllegalArgumentException("Unknown ObjectOperationAction code: $actionCode") + } + "objectId" -> objectId = unpacker.unpackString() + "mapOp" -> mapOp = readObjectMapOp(unpacker) + "counterOp" -> counterOp = readObjectCounterOp(unpacker) + "map" -> map = readObjectMap(unpacker) + "counter" -> counter = readObjectCounter(unpacker) + "nonce" -> nonce = unpacker.unpackString() + "initialValue" -> { + val size = unpacker.unpackBinaryHeader() + val bytes = ByteArray(size) + unpacker.readPayload(bytes) + initialValue = Binary(bytes) + } + "initialValueEncoding" -> initialValueEncoding = ProtocolMessageFormat.valueOf(unpacker.unpackString()) + else -> unpacker.skipValue() + } + } + + if (action == null) { + throw IllegalArgumentException("Missing required 'action' field in ObjectOperation") + } + + return ObjectOperation( + action = action, + objectId = objectId, + mapOp = mapOp, + counterOp = counterOp, + map = map, + counter = counter, + nonce = nonce, + initialValue = initialValue, + initialValueEncoding = initialValueEncoding + ) +} + +/** + * Write ObjectState to MessagePacker + */ +private fun ObjectState.writeMsgpack(packer: MessagePacker) { + var fieldCount = 3 // objectId, siteTimeserials, and tombstone are required + + if (createOp != null) fieldCount++ + if (map != null) fieldCount++ + if (counter != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + packer.packString("objectId") + packer.packString(objectId) + + packer.packString("siteTimeserials") + packer.packMapHeader(siteTimeserials.size) + for ((key, value) in siteTimeserials) { + packer.packString(key) + packer.packString(value) + } + + packer.packString("tombstone") + packer.packBoolean(tombstone) + + if (createOp != null) { + packer.packString("createOp") + createOp.writeMsgpack(packer) + } + + if (map != null) { + packer.packString("map") + map.writeMsgpack(packer) + } + + if (counter != null) { + packer.packString("counter") + counter.writeMsgpack(packer) + } +} + +/** + * Read ObjectState from MessageUnpacker + */ +private fun readObjectState(unpacker: MessageUnpacker): ObjectState { + val fieldCount = unpacker.unpackMapHeader() + + var objectId = "" + var siteTimeserials = mapOf() + var tombstone = false + var createOp: ObjectOperation? = null + var map: ObjectMap? = null + var counter: ObjectCounter? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "objectId" -> objectId = unpacker.unpackString() + "siteTimeserials" -> { + val mapSize = unpacker.unpackMapHeader() + val tempMap = mutableMapOf() + for (j in 0 until mapSize) { + val key = unpacker.unpackString() + val value = unpacker.unpackString() + tempMap[key] = value + } + siteTimeserials = tempMap + } + "tombstone" -> tombstone = unpacker.unpackBoolean() + "createOp" -> createOp = readObjectOperation(unpacker) + "map" -> map = readObjectMap(unpacker) + "counter" -> counter = readObjectCounter(unpacker) + else -> unpacker.skipValue() + } + } + + return ObjectState( + objectId = objectId, + siteTimeserials = siteTimeserials, + tombstone = tombstone, + createOp = createOp, + map = map, + counter = counter + ) +} + +/** + * Write ObjectMapOp to MessagePacker + */ +private fun ObjectMapOp.writeMsgpack(packer: MessagePacker) { + var fieldCount = 1 // key is required + + if (data != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + packer.packString("key") + packer.packString(key) + + if (data != null) { + packer.packString("data") + data.writeMsgpack(packer) + } +} + +/** + * Read ObjectMapOp from MessageUnpacker + */ +private fun readObjectMapOp(unpacker: MessageUnpacker): ObjectMapOp { + val fieldCount = unpacker.unpackMapHeader() + + var key = "" + var data: ObjectData? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "key" -> key = unpacker.unpackString() + "data" -> data = readObjectData(unpacker) + else -> unpacker.skipValue() + } + } + + return ObjectMapOp(key = key, data = data) +} + +/** + * Write ObjectCounterOp to MessagePacker + */ +private fun ObjectCounterOp.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (amount != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (amount != null) { + packer.packString("amount") + packer.packDouble(amount) + } +} + +/** + * Read ObjectCounterOp from MessageUnpacker + */ +private fun readObjectCounterOp(unpacker: MessageUnpacker): ObjectCounterOp { + val fieldCount = unpacker.unpackMapHeader() + + var amount: Double? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "amount" -> amount = unpacker.unpackDouble() + else -> unpacker.skipValue() + } + } + + return ObjectCounterOp(amount = amount) +} + +/** + * Write ObjectMap to MessagePacker + */ +private fun ObjectMap.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (semantics != null) fieldCount++ + if (entries != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (semantics != null) { + packer.packString("semantics") + packer.packInt(semantics.code) + } + + if (entries != null) { + packer.packString("entries") + packer.packMapHeader(entries.size) + for ((key, value) in entries) { + packer.packString(key) + value.writeMsgpack(packer) + } + } +} + +/** + * Read ObjectMap from MessageUnpacker + */ +private fun readObjectMap(unpacker: MessageUnpacker): ObjectMap { + val fieldCount = unpacker.unpackMapHeader() + + var semantics: MapSemantics? = null + var entries: Map? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "semantics" -> { + val semanticsCode = unpacker.unpackInt() + semantics = MapSemantics.entries.find { it.code == semanticsCode } + ?: throw IllegalArgumentException("Unknown MapSemantics code: $semanticsCode") + } + "entries" -> { + val mapSize = unpacker.unpackMapHeader() + val tempMap = mutableMapOf() + for (j in 0 until mapSize) { + val key = unpacker.unpackString() + val value = readObjectMapEntry(unpacker) + tempMap[key] = value + } + entries = tempMap + } + else -> unpacker.skipValue() + } + } + + return ObjectMap(semantics = semantics, entries = entries) +} + +/** + * Write ObjectCounter to MessagePacker + */ +private fun ObjectCounter.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (count != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (count != null) { + packer.packString("count") + packer.packDouble(count) + } +} + +/** + * Read ObjectCounter from MessageUnpacker + */ +private fun readObjectCounter(unpacker: MessageUnpacker): ObjectCounter { + val fieldCount = unpacker.unpackMapHeader() + + var count: Double? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "count" -> count = unpacker.unpackDouble() + else -> unpacker.skipValue() + } + } + + return ObjectCounter(count = count) +} + +/** + * Write ObjectMapEntry to MessagePacker + */ +private fun ObjectMapEntry.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (tombstone != null) fieldCount++ + if (timeserial != null) fieldCount++ + if (data != null) fieldCount++ + + packer.packMapHeader(fieldCount) + + if (tombstone != null) { + packer.packString("tombstone") + packer.packBoolean(tombstone) + } + + if (timeserial != null) { + packer.packString("timeserial") + packer.packString(timeserial) + } + + if (data != null) { + packer.packString("data") + data.writeMsgpack(packer) + } +} + +/** + * Read ObjectMapEntry from MessageUnpacker + */ +private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectMapEntry { + val fieldCount = unpacker.unpackMapHeader() + + var tombstone: Boolean? = null + var timeserial: String? = null + var data: ObjectData? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "tombstone" -> tombstone = unpacker.unpackBoolean() + "timeserial" -> timeserial = unpacker.unpackString() + "data" -> data = readObjectData(unpacker) + else -> unpacker.skipValue() + } + } + + return ObjectMapEntry(tombstone = tombstone, timeserial = timeserial, data = data) +} + +/** + * Write ObjectData to MessagePacker + */ +private fun ObjectData.writeMsgpack(packer: MessagePacker) { + var fieldCount = 0 + + if (objectId != null) fieldCount++ + value?.let { + fieldCount++ + if (it.value is JsonElement) { + fieldCount += 1 // For extra "encoding" field + } + } + + packer.packMapHeader(fieldCount) + + if (objectId != null) { + packer.packString("objectId") + packer.packString(objectId) + } + + if (value != null) { + when (val v = value.value) { + is Boolean -> { + packer.packString("boolean") + packer.packBoolean(v) + } + is String -> { + packer.packString("string") + packer.packString(v) + } + is Number -> { + packer.packString("number") + packer.packDouble(v.toDouble()) + } + is Binary -> { + packer.packString("bytes") + packer.packBinaryHeader(v.data.size) + packer.writePayload(v.data) + } + is JsonObject, is JsonArray -> { + packer.packString("string") + packer.packString(v.toString()) + packer.packString("encoding") + packer.packString("json") + } + } + } +} + +/** + * Read ObjectData from MessageUnpacker + */ +private fun readObjectData(unpacker: MessageUnpacker): ObjectData { + val fieldCount = unpacker.unpackMapHeader() + var objectId: String? = null + var value: ObjectValue? = null + var encoding: String? = null + var stringValue: String? = null + + for (i in 0 until fieldCount) { + val fieldName = unpacker.unpackString().intern() + val fieldFormat = unpacker.nextFormat + + if (fieldFormat == MessageFormat.NIL) { + unpacker.unpackNil() + continue + } + + when (fieldName) { + "objectId" -> objectId = unpacker.unpackString() + "boolean" -> value = ObjectValue(unpacker.unpackBoolean()) + "string" -> stringValue = unpacker.unpackString() + "number" -> value = ObjectValue(unpacker.unpackDouble()) + "bytes" -> { + val size = unpacker.unpackBinaryHeader() + val bytes = ByteArray(size) + unpacker.readPayload(bytes) + value = ObjectValue(Binary(bytes)) + } + "encoding" -> encoding = unpacker.unpackString() + else -> unpacker.skipValue() + } + } + + // Handle string with encoding if needed + if (stringValue != null && encoding == "json") { + val parsed = JsonParser.parseString(stringValue) + value = ObjectValue( + when { + parsed.isJsonObject -> parsed.asJsonObject + parsed.isJsonArray -> parsed.asJsonArray + else -> throw IllegalArgumentException("Invalid JSON string for encoding=json") + } + ) + } else if (stringValue != null) { + value = ObjectValue(stringValue) + } + + return ObjectData(objectId = objectId, value = value) +} From 1a3b35ce0de0e3d4cf57ff5a359b90f5ad9580e2 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 30 Jun 2025 19:16:05 +0530 Subject: [PATCH 3/4] [ECO-5386] Added unit tests for Liveobject serialization 1. Created dummy objectmessage fixture for different data types 2. Implemented unit tests with given fixture for various cases --- .../unit/ObjectMessageSerializationTest.kt | 180 ++++++++++++++++++ .../lib/objects/unit/ObjectMessageSizeTest.kt | 13 +- .../unit/fixtures/ObjectMessageFixtures.kt | 176 +++++++++++++++++ 3 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt new file mode 100644 index 000000000..2b832388e --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt @@ -0,0 +1,180 @@ +package io.ably.lib.objects.unit + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import io.ably.lib.objects.unit.fixtures.* +import io.ably.lib.types.ProtocolMessage +import io.ably.lib.types.ProtocolMessage.ActionSerializer +import io.ably.lib.types.ProtocolSerializer +import io.ably.lib.util.Serialisation +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ObjectMessageSerializationTest { + + private val objectMessages = arrayOf( + dummyObjectMessageWithStringData(), + dummyObjectMessageWithBinaryData(), + dummyObjectMessageWithNumberData(), + dummyObjectMessageWithBooleanData(), + dummyObjectMessageWithJsonObjectData(), + dummyObjectMessageWithJsonArrayData() + ) + + @Test + fun testObjectMessageMsgPackSerialization() = runTest { + val protocolMessage = ProtocolMessage() + protocolMessage.action = ProtocolMessage.Action.`object` + protocolMessage.state = objectMessages + + // Serialize the ProtocolMessage containing ObjectMessages to MsgPack format + val serializedProtoMsg = ProtocolSerializer.writeMsgpack(protocolMessage) + assertNotNull(serializedProtoMsg) + + // Deserialize back to ProtocolMessage + val deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedProtoMsg) + assertNotNull(deserializedProtoMsg) + + deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> + assertEquals(expected, actual as? io.ably.lib.objects.ObjectMessage) + } + } + + @Test + fun testObjectMessageJsonSerialization() = runTest { + val protocolMessage = ProtocolMessage() + protocolMessage.action = ProtocolMessage.Action.`object` + protocolMessage.state = objectMessages + + // Serialize the ProtocolMessage containing ObjectMessages to MsgPack format + val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8) + assertNotNull(serializedProtoMsg) + + // Deserialize back to ProtocolMessage + val deserializedProtoMsg = ProtocolSerializer.fromJSON(serializedProtoMsg) + assertNotNull(deserializedProtoMsg) + + deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> + assertEquals(expected, (actual as? io.ably.lib.objects.ObjectMessage)) + } + } + + @Test + fun testOmitNullsInObjectMessageSerialization() = runTest { + val objectMessage = dummyObjectMessageWithStringData() + val objectMessageWithNullFields = objectMessage.copy( + id = null, + timestamp = null, + clientId = "test-client", + connectionId = "test-connection", + extras = null, + operation = null, + objectState = null, + serial = null, + siteCode = null + ) + val protocolMessage = ProtocolMessage() + protocolMessage.action = ProtocolMessage.Action.`object` + protocolMessage.state = arrayOf(objectMessageWithNullFields) + + // check if Gson/Msgpack serialization omits null fields + fun assertSerializedObjectMessage(serializedProtoMsg: String) { + val deserializedProtoMsg = Gson().fromJson(serializedProtoMsg, JsonElement::class.java).asJsonObject + val serializedObjectMessage = deserializedProtoMsg.get("state").asJsonArray[0].asJsonObject.toString() + assertEquals("""{"clientId":"test-client","connectionId":"test-connection"}""", serializedObjectMessage) + } + + // Serialize using Gson + val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8) + assertSerializedObjectMessage(serializedProtoMsg) + + // Serialize using MsgPack + val serializedMsgpackBytes = ProtocolSerializer.writeMsgpack(protocolMessage) + val serializedJsonStringFromMsgpackBytes = Serialisation.msgpackToGson(serializedMsgpackBytes).toString() + assertSerializedObjectMessage(serializedJsonStringFromMsgpackBytes) + } + + @Test + fun testSerializeEnumsIntoOrdinalValues() = runTest { + val objectMessage = dummyObjectMessageWithStringData() + val protocolMessage = ProtocolMessage() + protocolMessage.action = ProtocolMessage.Action.`object` + protocolMessage.state = arrayOf(objectMessage) + + fun assertSerializedObjectMessage(serializedProtoMsg: String) { + val deserializedProtoMsg = Gson().fromJson(serializedProtoMsg, JsonElement::class.java).asJsonObject + val serializedObjectMessage = deserializedProtoMsg.get("state").asJsonArray[0].asJsonObject + val operation = serializedObjectMessage.get("operation").asJsonObject + assertTrue(operation.has("action")) + assertEquals(0, operation.get("action").asInt) // Check if action is serialized as code + } + + // Serialize using Gson + val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8) + assertSerializedObjectMessage(serializedProtoMsg) + // Serialize using MsgPack + val serializedMsgpackBytes = ProtocolSerializer.writeMsgpack(protocolMessage) + val serializedJsonStringFromMsgpackBytes = Serialisation.msgpackToGson(serializedMsgpackBytes).toString() + assertSerializedObjectMessage(serializedJsonStringFromMsgpackBytes) + } + + @Test + fun testHandleNullsInObjectMessageDeserialization() = runTest { + val protocolMessage = ProtocolMessage() + protocolMessage.id = "id" + protocolMessage.action = ProtocolMessage.Action.`object` + protocolMessage.state = null + + // Serialize using Gson with serializeNulls enabled + val gsonBuilderCreatingNulls = GsonBuilder() + .registerTypeAdapter(ProtocolMessage.Action::class.java, ActionSerializer()) + .serializeNulls().create() + + var protoMsgJsonObject = gsonBuilderCreatingNulls.toJsonTree(protocolMessage).asJsonObject + assertTrue(protoMsgJsonObject.has("state")) + assertEquals(JsonNull.INSTANCE, protoMsgJsonObject.get("state")) + + var deserializedProtoMsg = ProtocolSerializer.fromJSON(protoMsgJsonObject.toString()) + assertNull(deserializedProtoMsg.state) + + var serializedMsgpackBytes = Serialisation.gsonToMsgpack(protoMsgJsonObject) + deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedMsgpackBytes) + assertNull(deserializedProtoMsg.state) + + // Create ObjectMessage and serialize in a way that resulting string/bytes include null fields + val objectMessage = dummyObjectMessageWithStringData() + val objectMessageWithNullFields = objectMessage.copy( + id = null, + timestamp = null, + clientId = "test-client", + connectionId = "test-connection", + extras = null, + operation = objectMessage.operation?.copy( + initialValue = null, // initialValue set to null + mapOp = objectMessage.operation.mapOp?.copy( + data = null // objectData set to null + ) + ), + objectState = null, + serial = null, + siteCode = null + ) + protocolMessage.state = arrayOf(objectMessageWithNullFields) + protoMsgJsonObject = gsonBuilderCreatingNulls.toJsonTree(protocolMessage).asJsonObject + + // Check if gson deserialization works correctly + deserializedProtoMsg = ProtocolSerializer.fromJSON(protoMsgJsonObject.toString()) + assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? io.ably.lib.objects.ObjectMessage) + + // Check if msgpack deserialization works correctly + serializedMsgpackBytes = Serialisation.gsonToMsgpack(protoMsgJsonObject) + deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedMsgpackBytes) + assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? io.ably.lib.objects.ObjectMessage) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt index f4d368d89..d0c12fd78 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt @@ -1,5 +1,6 @@ package io.ably.lib.objects.unit +import com.google.gson.JsonObject import io.ably.lib.objects.* import io.ably.lib.objects.ObjectData import io.ably.lib.objects.ObjectMapOp @@ -17,6 +18,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.text.toByteArray class ObjectMessageSizeTest { @@ -32,10 +34,10 @@ class ObjectMessageSizeTest { timestamp = 1699123456789L, // Not counted in size calculation clientId = "test-client", // Size: 11 bytes (UTF-8 byte length) connectionId = "conn_98765", // Not counted in size calculation - extras = mapOf( // Size: JSON serialization byte length - "meta" to "data", // JSON: {"meta":"data","count":42} - "count" to 42 - ), // Total extras size: 26 bytes (verified by gson.toJson().length) + extras = JsonObject().apply { // Size: JSON serialization byte length + addProperty("meta", "data") // JSON: {"meta":"data","count":42} + addProperty("count", 42) + }, // Total extras size: 26 bytes (verified by gson.toJson().length) operation = ObjectOperation( action = ObjectOperationAction.MapCreate, objectId = "obj_54321", // Not counted in operation size @@ -45,7 +47,6 @@ class ObjectMessageSizeTest { key = "mapKey", // Size: 6 bytes (UTF-8 byte length) data = ObjectData( objectId = "ref_obj", // Not counted in data size - encoding = "utf-8", // Not counted in data size value = ObjectValue("sample") // Size: 6 bytes (UTF-8 byte length) ) // Total ObjectData size: 6 bytes ), // Total ObjectMapOp size: 6 + 6 = 12 bytes @@ -80,7 +81,7 @@ class ObjectMessageSizeTest { ), // Total ObjectCounter size: 8 bytes nonce = "nonce123", // Not counted in operation size - initialValue = Binary("initial".toByteArray()), // Not counted in operation size + initialValue = Binary("some-value".toByteArray()), // Not counted in operation size initialValueEncoding = ProtocolMessageFormat.Json // Not counted in operation size ), // Total ObjectOperation size: 12 + 8 + 26 + 8 = 54 bytes diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt new file mode 100644 index 000000000..37e74f935 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt @@ -0,0 +1,176 @@ +package io.ably.lib.objects.unit.fixtures + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.ably.lib.objects.* +import io.ably.lib.objects.Binary +import io.ably.lib.objects.ObjectData +import io.ably.lib.objects.ObjectMessage +import io.ably.lib.objects.ObjectState +import io.ably.lib.objects.ObjectValue + +internal val dummyObjectDataStringValue = ObjectData(objectId = "object-id", ObjectValue("dummy string")) + +internal val dummyBinaryObjectValue = ObjectData(objectId = "object-id", ObjectValue(Binary(byteArrayOf(1, 2, 3)))) + +internal val dummyNumberObjectValue = ObjectData(objectId = "object-id", ObjectValue(42.0)) + +internal val dummyBooleanObjectValue = ObjectData(objectId = "object-id", ObjectValue(true)) + +val dummyJsonObject = JsonObject().apply { addProperty("foo", "bar") } +internal val dummyJsonObjectValue = ObjectData(objectId = "object-id", ObjectValue(dummyJsonObject)) + +val dummyJsonArray = JsonArray().apply { add(1); add(2); add(3) } +internal val dummyJsonArrayValue = ObjectData(objectId = "object-id", ObjectValue(dummyJsonArray)) + +internal val dummyObjectMapEntry = ObjectMapEntry( + tombstone = false, + timeserial = "dummy-timeserial", + data = dummyObjectDataStringValue +) + +internal val dummyObjectMap = ObjectMap( + semantics = MapSemantics.LWW, + entries = mapOf("dummy-key" to dummyObjectMapEntry) +) + +internal val dummyObjectCounter = ObjectCounter( + count = 123.0 +) + +internal val dummyObjectMapOp = ObjectMapOp( + key = "dummy-key", + data = dummyObjectDataStringValue +) + +internal val dummyObjectCounterOp = ObjectCounterOp( + amount = 10.0 +) + +internal val dummyObjectOperation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = "dummy-object-id", + mapOp = dummyObjectMapOp, + counterOp = dummyObjectCounterOp, + map = dummyObjectMap, + counter = dummyObjectCounter, + nonce = "dummy-nonce", + initialValue = Binary("{\"foo\":\"bar\"}".toByteArray()) +) + +internal val dummyObjectState = ObjectState( + objectId = "dummy-object-id", + siteTimeserials = mapOf("site1" to "serial1"), + tombstone = false, + createOp = dummyObjectOperation, + map = dummyObjectMap, + counter = dummyObjectCounter +) + +internal val dummyObjectMessage = ObjectMessage( + id = "dummy-id", + timestamp = 1234567890L, + clientId = "dummy-client-id", + connectionId = "dummy-connection-id", + extras = JsonObject().apply { addProperty("meta", "data") }, + operation = dummyObjectOperation, + objectState = dummyObjectState, + serial = "dummy-serial", + siteCode = "dummy-site-code" +) + +internal fun dummyObjectMessageWithStringData(): ObjectMessage { + return dummyObjectMessage +} + +internal fun dummyObjectMessageWithBinaryData(): ObjectMessage { + val binaryObjectMapEntry = dummyObjectMapEntry.copy(data = dummyBinaryObjectValue) + val binaryObjectMap = dummyObjectMap.copy(entries = mapOf("dummy-key" to binaryObjectMapEntry)) + val binaryObjectMapOp = dummyObjectMapOp.copy(data = dummyBinaryObjectValue) + val binaryObjectOperation = dummyObjectOperation.copy( + mapOp = binaryObjectMapOp, + map = binaryObjectMap + ) + val binaryObjectState = dummyObjectState.copy( + map = binaryObjectMap, + createOp = binaryObjectOperation + ) + return dummyObjectMessage.copy( + operation = binaryObjectOperation, + objectState = binaryObjectState + ) +} + +internal fun dummyObjectMessageWithNumberData(): ObjectMessage { + val numberObjectMapEntry = dummyObjectMapEntry.copy(data = dummyNumberObjectValue) + val numberObjectMap = dummyObjectMap.copy(entries = mapOf("dummy-key" to numberObjectMapEntry)) + val numberObjectMapOp = dummyObjectMapOp.copy(data = dummyNumberObjectValue) + val numberObjectOperation = dummyObjectOperation.copy( + mapOp = numberObjectMapOp, + map = numberObjectMap + ) + val numberObjectState = dummyObjectState.copy( + map = numberObjectMap, + createOp = numberObjectOperation + ) + return dummyObjectMessage.copy( + operation = numberObjectOperation, + objectState = numberObjectState + ) +} + +internal fun dummyObjectMessageWithBooleanData(): ObjectMessage { + val booleanObjectMapEntry = dummyObjectMapEntry.copy(data = dummyBooleanObjectValue) + val booleanObjectMap = dummyObjectMap.copy(entries = mapOf("dummy-key" to booleanObjectMapEntry)) + val booleanObjectMapOp = dummyObjectMapOp.copy(data = dummyBooleanObjectValue) + val booleanObjectOperation = dummyObjectOperation.copy( + mapOp = booleanObjectMapOp, + map = booleanObjectMap + ) + val booleanObjectState = dummyObjectState.copy( + map = booleanObjectMap, + createOp = booleanObjectOperation + ) + return dummyObjectMessage.copy( + operation = booleanObjectOperation, + objectState = booleanObjectState + ) +} + +internal fun dummyObjectMessageWithJsonObjectData(): ObjectMessage { + val jsonObjectMapEntry = dummyObjectMapEntry.copy(data = dummyJsonObjectValue) + val jsonObjectMap = dummyObjectMap.copy(entries = mapOf("dummy-key" to jsonObjectMapEntry)) + val jsonObjectMapOp = dummyObjectMapOp.copy(data = dummyJsonObjectValue) + val jsonObjectOperation = dummyObjectOperation.copy( + action = ObjectOperationAction.MapSet, + mapOp = jsonObjectMapOp, + map = jsonObjectMap + ) + val jsonObjectState = dummyObjectState.copy( + map = jsonObjectMap, + createOp = jsonObjectOperation + ) + return dummyObjectMessage.copy( + operation = jsonObjectOperation, + objectState = jsonObjectState + ) +} + +internal fun dummyObjectMessageWithJsonArrayData(): ObjectMessage { + val jsonArrayMapEntry = dummyObjectMapEntry.copy(data = dummyJsonArrayValue) + val jsonArrayMap = dummyObjectMap.copy(entries = mapOf("dummy-key" to jsonArrayMapEntry)) + val jsonArrayMapOp = dummyObjectMapOp.copy(data = dummyJsonArrayValue) + val jsonArrayOperation = dummyObjectOperation.copy( + action = ObjectOperationAction.MapSet, + mapOp = jsonArrayMapOp, + map = jsonArrayMap + ) + val jsonArrayState = dummyObjectState.copy( + map = jsonArrayMap, + createOp = jsonArrayOperation + ) + return dummyObjectMessage.copy( + operation = jsonArrayOperation, + objectState = jsonArrayState + ) +} From b5c1554e6eeb21ffbdde3a654292e76f3f08ee72 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 30 Jun 2025 19:48:48 +0530 Subject: [PATCH 4/4] [ECO-5386] Fixed serialization and related helpers as per review comments --- .../io/ably/lib/objects/LiveObjectsHelper.java | 18 ++++++++++-------- .../serialization/MsgpackSerialization.kt | 10 +++++----- .../unit/ObjectMessageSerializationTest.kt | 2 +- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectsHelper.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectsHelper.java index 78d6c35d3..4edcbe9ef 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsHelper.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsHelper.java @@ -27,14 +27,16 @@ public static LiveObjectsPlugin tryInitializeLiveObjectsPlugin(AblyRealtime ably public static LiveObjectSerializer getLiveObjectSerializer() { if (liveObjectSerializer == null) { synchronized (LiveObjectsHelper.class) { - try { - Class serializerClass = Class.forName("io.ably.lib.objects.serialization.DefaultLiveObjectSerializer"); - liveObjectSerializer = (LiveObjectSerializer) serializerClass.getDeclaredConstructor().newInstance(); - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | - NoSuchMethodException | - InvocationTargetException e) { - Log.e(TAG, "Failed to init LiveObjectSerializer, LiveObjects plugin not included in the classpath", e); - return null; + if (liveObjectSerializer == null) { // Double-Checked Locking (DCL) + try { + Class serializerClass = Class.forName("io.ably.lib.objects.serialization.DefaultLiveObjectSerializer"); + liveObjectSerializer = (LiveObjectSerializer) serializerClass.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | + NoSuchMethodException | + InvocationTargetException e) { + Log.e(TAG, "Failed to init LiveObjectSerializer, LiveObjects plugin not included in the classpath", e); + return null; + } } } } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt index 73bb29a31..86903a951 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt @@ -149,8 +149,9 @@ internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { */ private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { var fieldCount = 1 // action is always required + require(objectId.isNotEmpty()) { "objectId must be non-empty per LiveObjects protocol" } + fieldCount++ - if (objectId.isNotEmpty()) fieldCount++ if (mapOp != null) fieldCount++ if (counterOp != null) fieldCount++ if (map != null) fieldCount++ @@ -164,10 +165,9 @@ private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { packer.packString("action") packer.packInt(action.code) - if (objectId.isNotEmpty()) { - packer.packString("objectId") - packer.packString(objectId) - } + // Always include objectId as per LiveObjects protocol + packer.packString("objectId") + packer.packString(objectId) if (mapOp != null) { packer.packString("mapOp") diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt index 2b832388e..f8c37ee7c 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt @@ -52,7 +52,7 @@ class ObjectMessageSerializationTest { protocolMessage.action = ProtocolMessage.Action.`object` protocolMessage.state = objectMessages - // Serialize the ProtocolMessage containing ObjectMessages to MsgPack format + // Serialize the ProtocolMessage containing ObjectMessages to Json format val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8) assertNotNull(serializedProtoMsg)