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")); } 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) +} 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 + ) +}