diff --git a/lib/src/main/java/io/ably/lib/objects/Adapter.java b/lib/src/main/java/io/ably/lib/objects/Adapter.java index 804fa59c8..b8fa905cd 100644 --- a/lib/src/main/java/io/ably/lib/objects/Adapter.java +++ b/lib/src/main/java/io/ably/lib/objects/Adapter.java @@ -3,9 +3,11 @@ import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.ChannelState; import io.ably.lib.realtime.CompletionListener; +import io.ably.lib.transport.ConnectionManager; import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelMode; import io.ably.lib.types.ChannelOptions; +import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ProtocolMessage; import io.ably.lib.util.Log; import org.jetbrains.annotations.NotNull; @@ -65,4 +67,19 @@ public ChannelState getChannelState(@NotNull String channelName) { Log.e(TAG, "getChannelState(): channel not found: " + channelName); return null; } + + @Override + public @NotNull ClientOptions getClientOptions() { + return ably.options; + } + + @Override + public @NotNull ConnectionManager getConnectionManager() { + return ably.connection.connectionManager; + } + + @Override + public long getTime() throws AblyException { + return ably.time(); + } } diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjects.java b/lib/src/main/java/io/ably/lib/objects/LiveObjects.java index a68822d5f..33db8b63c 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjects.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjects.java @@ -3,11 +3,11 @@ import io.ably.lib.objects.state.ObjectsStateChange; import io.ably.lib.objects.type.counter.LiveCounter; import io.ably.lib.objects.type.map.LiveMap; +import io.ably.lib.objects.type.map.LiveMapValue; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; - import java.util.Map; /** @@ -33,46 +33,60 @@ public interface LiveObjects extends ObjectsStateChange { LiveMap getRoot(); /** - * Creates a new LiveMap based on an existing LiveMap. + * Creates a new empty LiveMap with no entries. * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. * Once the ACK message is received, the method returns the object from the local pool if it got created due to * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. + * and returns it. * - * @param liveMap the existing LiveMap to base the new LiveMap on. - * @return the newly created LiveMap instance. + * @return the newly created empty LiveMap instance. */ @Blocking @NotNull - LiveMap createMap(@NotNull LiveMap liveMap); + LiveMap createMap(); /** - * Creates a new LiveMap based on a LiveCounter. + * Creates a new LiveMap with type-safe entries that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. + * Implements spec RTO11 : createMap(Dict entries?) * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. * Once the ACK message is received, the method returns the object from the local pool if it got created due to * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally * using the provided data and returns it. * - * @param liveCounter the LiveCounter to base the new LiveMap on. + *

Example:

+ *
{@code
+     * Map entries = Map.of(
+     *     "string", LiveMapValue.of("Hello"),
+     *     "number", LiveMapValue.of(42),
+     *     "boolean", LiveMapValue.of(true),
+     *     "binary", LiveMapValue.of(new byte[]{1, 2, 3}),
+     *     "array", LiveMapValue.of(new JsonArray()),
+     *     "object", LiveMapValue.of(new JsonObject()),
+     *     "counter", LiveMapValue.of(liveObjects.createCounter()),
+     *     "nested", LiveMapValue.of(liveObjects.createMap())
+     * );
+     * LiveMap map = liveObjects.createMap(entries);
+     * }
+ * + * @param entries the type-safe map entries with values that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. * @return the newly created LiveMap instance. */ @Blocking @NotNull - LiveMap createMap(@NotNull LiveCounter liveCounter); + LiveMap createMap(@NotNull Map entries); /** - * Creates a new LiveMap based on a standard Java Map. - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. + * Creates a new LiveCounter with an initial value of 0. + * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally + * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally * using the provided data and returns it. * - * @param map the Java Map to base the new LiveMap on. - * @return the newly created LiveMap instance. + * @return the newly created LiveCounter instance with initial value of 0. */ @Blocking @NotNull - LiveMap createMap(@NotNull Map map); + LiveCounter createCounter(); /** * Creates a new LiveCounter with an initial value. @@ -86,7 +100,7 @@ public interface LiveObjects extends ObjectsStateChange { */ @Blocking @NotNull - LiveCounter createCounter(@NotNull Long initialValue); + LiveCounter createCounter(@NotNull Number initialValue); /** * Asynchronously retrieves the root LiveMap object. @@ -100,43 +114,42 @@ public interface LiveObjects extends ObjectsStateChange { void getRootAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback); /** - * Asynchronously creates a new LiveMap based on an existing LiveMap. + * Asynchronously creates a new empty LiveMap with no entries. * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. * Once the ACK message is received, the method returns the object from the local pool if it got created due to * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. + * and returns it. * - * @param liveMap the existing LiveMap to base the new LiveMap on. * @param callback the callback to handle the result or error. */ @NonBlocking - void createMapAsync(@NotNull LiveMap liveMap, @NotNull ObjectsCallback<@NotNull LiveMap> callback); + void createMapAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback); /** - * Asynchronously creates a new LiveMap based on a LiveCounter. + * Asynchronously creates a new LiveMap with type-safe entries that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. + * This method implements the spec RTO11 signature: createMap(Dict entries?) * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. * Once the ACK message is received, the method returns the object from the local pool if it got created due to * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally * using the provided data and returns it. * - * @param liveCounter the LiveCounter to base the new LiveMap on. + * @param entries the type-safe map entries with values that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. * @param callback the callback to handle the result or error. */ @NonBlocking - void createMapAsync(@NotNull LiveCounter liveCounter, @NotNull ObjectsCallback<@NotNull LiveMap> callback); + void createMapAsync(@NotNull Map entries, @NotNull ObjectsCallback<@NotNull LiveMap> callback); /** - * Asynchronously creates a new LiveMap based on a standard Java Map. - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. + * Asynchronously creates a new LiveCounter with an initial value of 0. + * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally + * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally * using the provided data and returns it. * - * @param map the Java Map to base the new LiveMap on. * @param callback the callback to handle the result or error. */ @NonBlocking - void createMapAsync(@NotNull Map map, @NotNull ObjectsCallback<@NotNull LiveMap> callback); + void createCounterAsync(@NotNull ObjectsCallback<@NotNull LiveCounter> callback); /** * Asynchronously creates a new LiveCounter with an initial value. @@ -149,5 +162,5 @@ public interface LiveObjects extends ObjectsStateChange { * @param callback the callback to handle the result or error. */ @NonBlocking - void createCounterAsync(@NotNull Long initialValue, @NotNull ObjectsCallback<@NotNull LiveCounter> callback); + void createCounterAsync(@NotNull Number initialValue, @NotNull ObjectsCallback<@NotNull LiveCounter> callback); } diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java index 690bc7495..fd074fd66 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java @@ -2,9 +2,12 @@ import io.ably.lib.realtime.ChannelState; import io.ably.lib.realtime.CompletionListener; +import io.ably.lib.transport.ConnectionManager; import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelMode; +import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ProtocolMessage; +import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -53,5 +56,30 @@ public interface LiveObjectsAdapter { * @return the current state of the specified channel, or null if the channel is not found */ @Nullable ChannelState getChannelState(@NotNull String channelName); + + /** + * Retrieves the client options configured for the Ably client. + * Used to access client configuration parameters such as echoMessages setting + * that affect the behavior of LiveObjects operations. + * + * @return the client options containing configuration parameters + */ + @NotNull ClientOptions getClientOptions(); + + /** + * Retrieves the connection manager for handling connection state and operations. + * Used to check connection status, obtain error information, and manage + * message transmission across the Ably connection. + * + * @return the connection manager instance + */ + @NotNull ConnectionManager getConnectionManager(); + + /** + * Retrieves the current time in milliseconds from the Ably server. + * Spec: RTO16 + */ + @Blocking + long getTime() throws AblyException; } diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java index 54e7a3130..c23ccc91b 100644 --- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java +++ b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java @@ -14,47 +14,57 @@ public interface LiveCounter extends LiveCounterChange { /** - * Increments the value of the counter by 1. + * Increments the value of the counter by the specified amount. * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular * operation application procedure. + * Spec: RTLC12 + * + * @param amount the amount by which to increment the counter + */ + @Blocking + void increment(@NotNull Number amount); + + /** + * Decrements the value of the counter by the specified amount. + * An alias for calling {@link LiveCounter#increment(Number)} with a negative amount. + * Spec: RTLC13 + * + * @param amount the amount by which to decrement the counter */ @Blocking - void increment(); + void decrement(@NotNull Number amount); /** - * Increments the value of the counter by 1 asynchronously. + * Increments the value of the counter by the specified amount asynchronously. * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular * operation application procedure. + * Spec: RTLC12 * + * @param amount the amount by which to increment the counter * @param callback the callback to be invoked upon completion of the operation. */ @NonBlocking - void incrementAsync(@NotNull ObjectsCallback callback); - - /** - * Decrements the value of the counter by 1. - * An alias for calling {@link LiveCounter#increment()} with a negative amount. - */ - @Blocking - void decrement(); + void incrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback); /** - * Decrements the value of the counter by 1 asynchronously. - * An alias for calling {@link LiveCounter#increment()} with a negative amount. + * Decrements the value of the counter by the specified amount asynchronously. + * An alias for calling {@link LiveCounter#incrementAsync(Number, ObjectsCallback)} with a negative amount. + * Spec: RTLC13 * + * @param amount the amount by which to decrement the counter * @param callback the callback to be invoked upon completion of the operation. */ @NonBlocking - void decrementAsync(@NotNull ObjectsCallback callback); + void decrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback); /** * Retrieves the current value of the counter. * - * @return the current value of the counter as a Long. + * @return the current value of the counter as a Double. */ @NotNull @Contract(pure = true) // Indicates this method does not modify the state of the object. diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java index e66a63728..a6fae9ce0 100644 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java +++ b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java @@ -30,7 +30,7 @@ public interface LiveMap extends LiveMapChange { * @return the value associated with the specified key, or null if the key does not exist. */ @Nullable - Object get(@NotNull String keyName); + LiveMapValue get(@NotNull String keyName); /** * Retrieves all entries (key-value pairs) in the map. @@ -40,7 +40,7 @@ public interface LiveMap extends LiveMapChange { */ @NotNull @Unmodifiable - Iterable> entries(); + Iterable> entries(); /** * Retrieves all keys in the map. @@ -60,7 +60,7 @@ public interface LiveMap extends LiveMapChange { */ @NotNull @Unmodifiable - Iterable values(); + Iterable values(); /** * Sets the specified key to the given value in the map. @@ -68,12 +68,13 @@ public interface LiveMap extends LiveMapChange { * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when * the published MAP_SET operation is echoed back to the client and applied to the object following the regular * operation application procedure. + * Spec: RTLM20 * * @param keyName the key to be set. * @param value the value to be associated with the key. */ @Blocking - void set(@NotNull String keyName, @NotNull Object value); + void set(@NotNull String keyName, @NotNull LiveMapValue value); /** * Removes the specified key and its associated value from the map. @@ -81,6 +82,7 @@ public interface LiveMap extends LiveMapChange { * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular * operation application procedure. + * Spec: RTLM21 * * @param keyName the key to be removed. */ @@ -103,13 +105,14 @@ public interface LiveMap extends LiveMapChange { * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when * the published MAP_SET operation is echoed back to the client and applied to the object following the regular * operation application procedure. + * Spec: RTLM20 * * @param keyName the key to be set. * @param value the value to be associated with the key. * @param callback the callback to handle the result or any errors. */ @NonBlocking - void setAsync(@NotNull String keyName, @NotNull Object value, @NotNull ObjectsCallback callback); + void setAsync(@NotNull String keyName, @NotNull LiveMapValue value, @NotNull ObjectsCallback callback); /** * Asynchronously removes the specified key and its associated value from the map. @@ -117,6 +120,7 @@ public interface LiveMap extends LiveMapChange { * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular * operation application procedure. + * Spec: RTLM21 * * @param keyName the key to be removed. * @param callback the callback to handle the result or any errors. diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java new file mode 100644 index 000000000..ccba80330 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java @@ -0,0 +1,443 @@ +package io.ably.lib.objects.type.map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.ably.lib.objects.type.counter.LiveCounter; +import org.jetbrains.annotations.NotNull; + +/** + * Abstract class representing the union type for LiveMap values. + * Provides strict compile-time type safety, implementation is similar to Gson's JsonElement pattern. + * Spec: RTO11a1 - Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap + */ +public abstract class LiveMapValue { + + /** + * Gets the underlying value. + * + * @return the value as an Object + */ + @NotNull + public abstract Object getValue(); + + /** + * Type checking methods with default implementations + */ + + /** + * Returns true if this LiveMapValue represents a Boolean value. + * + * @return true if this is a Boolean value + */ + public boolean isBoolean() { return false; } + + /** + * Returns true if this LiveMapValue represents a Binary value. + * + * @return true if this is a Binary value + */ + public boolean isBinary() { return false; } + + /** + * Returns true if this LiveMapValue represents a Number value. + * + * @return true if this is a Number value + */ + public boolean isNumber() { return false; } + + /** + * Returns true if this LiveMapValue represents a String value. + * + * @return true if this is a String value + */ + public boolean isString() { return false; } + + /** + * Returns true if this LiveMapValue represents a JsonArray value. + * + * @return true if this is a JsonArray value + */ + public boolean isJsonArray() { return false; } + + /** + * Returns true if this LiveMapValue represents a JsonObject value. + * + * @return true if this is a JsonObject value + */ + public boolean isJsonObject() { return false; } + + /** + * Returns true if this LiveMapValue represents a LiveCounter value. + * + * @return true if this is a LiveCounter value + */ + public boolean isLiveCounter() { return false; } + + /** + * Returns true if this LiveMapValue represents a LiveMap value. + * + * @return true if this is a LiveMap value + */ + public boolean isLiveMap() { return false; } + + /** + * Getter methods with default implementations that throw exceptions + */ + + /** + * Gets the Boolean value if this LiveMapValue represents a Boolean. + * + * @return the Boolean value + * @throws IllegalStateException if this is not a Boolean value + */ + @NotNull + public Boolean getAsBoolean() { + throw new IllegalStateException("Not a Boolean value"); + } + + /** + * Gets the Binary value if this LiveMapValue represents a Binary. + * + * @return the Binary value + * @throws IllegalStateException if this is not a Binary value + */ + public byte @NotNull [] getAsBinary() { + throw new IllegalStateException("Not a Binary value"); + } + + /** + * Gets the Number value if this LiveMapValue represents a Number. + * + * @return the Number value + * @throws IllegalStateException if this is not a Number value + */ + @NotNull + public Number getAsNumber() { + throw new IllegalStateException("Not a Number value"); + } + + /** + * Gets the String value if this LiveMapValue represents a String. + * + * @return the String value + * @throws IllegalStateException if this is not a String value + */ + @NotNull + public String getAsString() { + throw new IllegalStateException("Not a String value"); + } + + /** + * Gets the JsonArray value if this LiveMapValue represents a JsonArray. + * + * @return the JsonArray value + * @throws IllegalStateException if this is not a JsonArray value + */ + @NotNull + public JsonArray getAsJsonArray() { + throw new IllegalStateException("Not a JsonArray value"); + } + + /** + * Gets the JsonObject value if this LiveMapValue represents a JsonObject. + * + * @return the JsonObject value + * @throws IllegalStateException if this is not a JsonObject value + */ + @NotNull + public JsonObject getAsJsonObject() { + throw new IllegalStateException("Not a JsonObject value"); + } + + /** + * Gets the LiveCounter value if this LiveMapValue represents a LiveCounter. + * + * @return the LiveCounter value + * @throws IllegalStateException if this is not a LiveCounter value + */ + @NotNull + public LiveCounter getAsLiveCounter() { + throw new IllegalStateException("Not a LiveCounter value"); + } + + /** + * Gets the LiveMap value if this LiveMapValue represents a LiveMap. + * + * @return the LiveMap value + * @throws IllegalStateException if this is not a LiveMap value + */ + @NotNull + public LiveMap getAsLiveMap() { + throw new IllegalStateException("Not a LiveMap value"); + } + + /** + * Static factory methods similar to JsonElement constructors + */ + + /** + * Creates a LiveMapValue from a Boolean. + * + * @param value the boolean value + * @return a LiveMapValue containing the boolean + */ + @NotNull + public static LiveMapValue of(@NotNull Boolean value) { + return new BooleanValue(value); + } + + /** + * Creates a LiveMapValue from a Binary. + * + * @param value the binary value + * @return a LiveMapValue containing the binary + */ + @NotNull + public static LiveMapValue of(byte @NotNull [] value) { + return new BinaryValue(value); + } + + /** + * Creates a LiveMapValue from a Number. + * + * @param value the number value + * @return a LiveMapValue containing the number + */ + @NotNull + public static LiveMapValue of(@NotNull Number value) { + return new NumberValue(value); + } + + /** + * Creates a LiveMapValue from a String. + * + * @param value the string value + * @return a LiveMapValue containing the string + */ + @NotNull + public static LiveMapValue of(@NotNull String value) { + return new StringValue(value); + } + + /** + * Creates a LiveMapValue from a JsonArray. + * + * @param value the JsonArray value + * @return a LiveMapValue containing the JsonArray + */ + @NotNull + public static LiveMapValue of(@NotNull JsonArray value) { + return new JsonArrayValue(value); + } + + /** + * Creates a LiveMapValue from a JsonObject. + * + * @param value the JsonObject value + * @return a LiveMapValue containing the JsonObject + */ + @NotNull + public static LiveMapValue of(@NotNull JsonObject value) { + return new JsonObjectValue(value); + } + + /** + * Creates a LiveMapValue from a LiveCounter. + * + * @param value the LiveCounter value + * @return a LiveMapValue containing the LiveCounter + */ + @NotNull + public static LiveMapValue of(@NotNull LiveCounter value) { + return new LiveCounterValue(value); + } + + /** + * Creates a LiveMapValue from a LiveMap. + * + * @param value the LiveMap value + * @return a LiveMapValue containing the LiveMap + */ + @NotNull + public static LiveMapValue of(@NotNull LiveMap value) { + return new LiveMapValueWrapper(value); + } + + // Concrete implementations for each allowed type + + /** + * Boolean value implementation. + */ + private static final class BooleanValue extends LiveMapValue { + private final Boolean value; + + BooleanValue(@NotNull Boolean value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isBoolean() { return true; } + + @Override + public @NotNull Boolean getAsBoolean() { return value; } + } + + /** + * Binary value implementation. + */ + private static final class BinaryValue extends LiveMapValue { + private final byte[] value; + + BinaryValue(byte @NotNull [] value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isBinary() { return true; } + + @Override + public byte @NotNull [] getAsBinary() { return value; } + } + + /** + * Number value implementation. + */ + private static final class NumberValue extends LiveMapValue { + private final Number value; + + NumberValue(@NotNull Number value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isNumber() { return true; } + + @Override + public @NotNull Number getAsNumber() { return value; } + } + + /** + * String value implementation. + */ + private static final class StringValue extends LiveMapValue { + private final String value; + + StringValue(@NotNull String value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isString() { return true; } + + @Override + public @NotNull String getAsString() { return value; } + } + + /** + * JsonArray value implementation. + */ + private static final class JsonArrayValue extends LiveMapValue { + private final JsonArray value; + + JsonArrayValue(@NotNull JsonArray value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isJsonArray() { return true; } + + @Override + public @NotNull JsonArray getAsJsonArray() { return value; } + } + + /** + * JsonObject value implementation. + */ + private static final class JsonObjectValue extends LiveMapValue { + private final JsonObject value; + + JsonObjectValue(@NotNull JsonObject value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isJsonObject() { return true; } + + @Override + public @NotNull JsonObject getAsJsonObject() { return value; } + } + + /** + * LiveCounter value implementation. + */ + private static final class LiveCounterValue extends LiveMapValue { + private final LiveCounter value; + + LiveCounterValue(@NotNull LiveCounter value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isLiveCounter() { return true; } + + @Override + public @NotNull LiveCounter getAsLiveCounter() { return value; } + } + + /** + * LiveMap value implementation. + */ + private static final class LiveMapValueWrapper extends LiveMapValue { + private final LiveMap value; + + LiveMapValueWrapper(@NotNull LiveMap value) { + this.value = value; + } + + @Override + public @NotNull Object getValue() { + return value; + } + + @Override + public boolean isLiveMap() { return true; } + + @Override + public @NotNull LiveMap getAsLiveMap() { return value; } + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjects.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjects.kt index 0ffaeacf1..eebe6dc4d 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjects.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjects.kt @@ -1,9 +1,14 @@ package io.ably.lib.objects +import io.ably.lib.objects.serialization.gson import io.ably.lib.objects.state.ObjectsStateChange import io.ably.lib.objects.state.ObjectsStateEvent +import io.ably.lib.objects.type.ObjectType import io.ably.lib.objects.type.counter.LiveCounter +import io.ably.lib.objects.type.livecounter.DefaultLiveCounter +import io.ably.lib.objects.type.livemap.DefaultLiveMap import io.ably.lib.objects.type.map.LiveMap +import io.ably.lib.objects.type.map.LiveMapValue import io.ably.lib.realtime.ChannelState import io.ably.lib.types.AblyException import io.ably.lib.types.ProtocolMessage @@ -47,7 +52,7 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val /** * Provides a channel-specific scope for safely executing asynchronous operations with callbacks. */ - private val asyncScope = ObjectsAsyncScope(channelName) + internal val asyncScope = ObjectsAsyncScope(channelName) init { incomingObjectsHandler = initializeHandlerForIncomingObjectMessages() @@ -55,40 +60,28 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val override fun getRoot(): LiveMap = runBlocking { getRootAsync() } - override fun createMap(liveMap: LiveMap): LiveMap { - TODO("Not yet implemented") - } + override fun createMap(): LiveMap = createMap(mutableMapOf()) - override fun createMap(liveCounter: LiveCounter): LiveMap { - TODO("Not yet implemented") - } + override fun createMap(entries: MutableMap): LiveMap = runBlocking { createMapAsync(entries) } - override fun createMap(map: MutableMap): LiveMap { - TODO("Not yet implemented") - } + override fun createCounter(): LiveCounter = createCounter(0) + + override fun createCounter(initialValue: Number): LiveCounter = runBlocking { createCounterAsync(initialValue) } override fun getRootAsync(callback: ObjectsCallback) { asyncScope.launchWithCallback(callback) { getRootAsync() } } - override fun createMapAsync(liveMap: LiveMap, callback: ObjectsCallback) { - TODO("Not yet implemented") - } + override fun createMapAsync(callback: ObjectsCallback) = createMapAsync(mutableMapOf(), callback) - override fun createMapAsync(liveCounter: LiveCounter, callback: ObjectsCallback) { - TODO("Not yet implemented") + override fun createMapAsync(entries: MutableMap, callback: ObjectsCallback) { + asyncScope.launchWithCallback(callback) { createMapAsync(entries) } } - override fun createMapAsync(map: MutableMap, callback: ObjectsCallback) { - TODO("Not yet implemented") - } - - override fun createCounterAsync(initialValue: Long, callback: ObjectsCallback) { - TODO("Not yet implemented") - } + override fun createCounterAsync(callback: ObjectsCallback) = createCounterAsync(0, callback) - override fun createCounter(initialValue: Long): LiveCounter { - TODO("Not yet implemented") + override fun createCounterAsync(initialValue: Number, callback: ObjectsCallback) { + asyncScope.launchWithCallback(callback) { createCounterAsync(initialValue) } } override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription = @@ -104,6 +97,101 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val objectsPool.get(ROOT_OBJECT_ID) as LiveMap } + private suspend fun createMapAsync(entries: MutableMap): LiveMap { + adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO11c, RTO11d, RTO11e + + if (entries.keys.any { it.isEmpty() }) { // RTO11f2 + throw invalidInputError("Map keys should not be empty") + } + + // RTO11f4 - Create initial value operation + val initialMapValue = DefaultLiveMap.initialValue(entries) + + // RTO11f5 - Create initial value JSON string + val initialValueJSONString = gson.toJson(initialMapValue) + + // RTO11f8 - Create object ID from initial value + val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Map, initialValueJSONString) + + // Create ObjectMessage with the operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.MapCreate, + objectId = objectId, + map = initialMapValue.map, + nonce = nonce, + initialValue = initialValueJSONString, + ) + ) + + // RTO11g - Publish the message + publish(arrayOf(msg)) + + // RTO11h - Check if object already exists in pool, otherwise create a zero-value object using the sequential scope + return objectsPool.get(objectId) as? LiveMap ?: withContext(sequentialScope.coroutineContext) { + objectsPool.createZeroValueObjectIfNotExists(objectId) as LiveMap + } + } + + private suspend fun createCounterAsync(initialValue: Number): LiveCounter { + adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO12c, RTO12d, RTO12e + + // Validate input parameter + if (initialValue.toDouble().isNaN() || initialValue.toDouble().isInfinite()) { + throw invalidInputError("Counter value should be a valid number") + } + + // RTO12f2 + val initialCounterValue = DefaultLiveCounter.initialValue(initialValue) + // RTO12f3 - Create initial value operation + val initialValueJSONString = gson.toJson(initialCounterValue) + + // RTO12f6- Create object ID from initial value + val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Counter, initialValueJSONString) + + // Create ObjectMessage with the operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.CounterCreate, + objectId = objectId, + counter = initialCounterValue.counter, + nonce = nonce, + initialValue = initialValueJSONString + ) + ) + + // RTO12g - Publish the message + publish(arrayOf(msg)) + + // RTO12h - Check if object already exists in pool, otherwise create a zero-value object using the sequential scope + return objectsPool.get(objectId) as? LiveCounter ?: withContext(sequentialScope.coroutineContext) { + objectsPool.createZeroValueObjectIfNotExists(objectId) as LiveCounter + } + } + + /** + * Spec: RTO11f8, RTO12f6 + */ + private suspend fun getObjectIdStringWithNonce(objectType: ObjectType, initialValue: String): Pair { + val nonce = generateNonce() + val msTimestamp = ServerTime.getCurrentTime(adapter) // RTO16 - Get server time for nonce generation + return Pair(ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp).toString(), nonce) + } + + /** + * Spec: RTO15 + */ + internal suspend fun publish(objectMessages: Array) { + // RTO15b, RTL6c - Ensure that the channel is in a valid state for publishing + adapter.throwIfUnpublishableState(channelName) + adapter.ensureMessageSizeWithinLimit(objectMessages) + // RTO15e - Must construct the ProtocolMessage as per RTO15e1, RTO15e2, RTO15e3 + val protocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`, channelName) + protocolMessage.state = objectMessages + // RTO15f, RTO15g - Send the ProtocolMessage using the adapter and capture success/failure + adapter.sendAsync(protocolMessage) + } + /** * Handles a ProtocolMessage containing proto action as `object` or `object_sync`. * @spec RTL1 - Processes incoming object messages and object sync messages diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt index 5608491a3..17612b043 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt @@ -6,7 +6,7 @@ internal enum class ErrorCode(public val code: Int) { MaxMessageSizeExceeded(40_009), InvalidObject(92_000), // LiveMap specific error codes - MapKeyShouldBeString(40_003), + InvalidInputParams(40_003), MapValueDataTypeUnsupported(40_013), // Channel mode and state validation error codes ChannelModeRequired(40_024), 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 00e079bf3..56af0cba2 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 @@ -9,6 +9,9 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +/** + * Spec: RTO15g + */ internal suspend fun LiveObjectsAdapter.sendAsync(message: ProtocolMessage) = suspendCancellableCoroutine { continuation -> try { this.send(message, object : CompletionListener { @@ -25,11 +28,14 @@ internal suspend fun LiveObjectsAdapter.sendAsync(message: ProtocolMessage) = su } } +/** + * Spec: RTO15d + */ internal fun LiveObjectsAdapter.ensureMessageSizeWithinLimit(objectMessages: Array) { val maximumAllowedSize = maxMessageSizeLimit() val objectsTotalMessageSize = objectMessages.sumOf { it.size() } if (objectsTotalMessageSize > maximumAllowedSize) { - throw ablyException("ObjectMessage size $objectsTotalMessageSize exceeds maximum allowed size of $maximumAllowedSize bytes", + throw ablyException("ObjectMessages size $objectsTotalMessageSize exceeds maximum allowed size of $maximumAllowedSize bytes", ErrorCode.MaxMessageSizeExceeded) } } @@ -47,6 +53,19 @@ internal fun LiveObjectsAdapter.throwIfInvalidAccessApiConfiguration(channelName throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed)) } +internal fun LiveObjectsAdapter.throwIfInvalidWriteApiConfiguration(channelName: String) { + throwIfEchoMessagesDisabled() + throwIfMissingChannelMode(channelName, ChannelMode.object_publish) + throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed, ChannelState.suspended)) +} + +internal fun LiveObjectsAdapter.throwIfUnpublishableState(channelName: String) { + if (!connectionManager.isActive) { + throw ablyException(connectionManager.stateErrorInfo) + } + throwIfInChannelState(channelName, arrayOf(ChannelState.failed, ChannelState.suspended)) +} + // Spec: RTO2 internal fun LiveObjectsAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) { val channelModes = getChannelModes(channelName) @@ -63,6 +82,12 @@ internal fun LiveObjectsAdapter.throwIfInChannelState(channelName: String, chann } } +internal fun LiveObjectsAdapter.throwIfEchoMessagesDisabled() { + if (!clientOptions.echoMessages) { + throw clientError("\"echoMessages\" client option must be enabled for this operation") + } +} + internal class Binary(val data: ByteArray) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -78,3 +103,11 @@ internal class Binary(val data: ByteArray) { internal fun Binary.size(): Int { return data.size } + +internal data class CounterCreatePayload( + val counter: ObjectCounter +) + +internal data class MapCreatePayload( + val map: ObjectMap +) diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt index d948ff32f..64a040ddc 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt @@ -1,6 +1,9 @@ package io.ably.lib.objects import io.ably.lib.objects.type.ObjectType +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 internal class ObjectId private constructor( internal val type: ObjectType, @@ -9,16 +12,30 @@ internal class ObjectId private constructor( ) { /** * Converts ObjectId to string representation. + * Spec: RTO6b1 */ override fun toString(): String { return "${type.value}:$hash@$timestampMs" } companion object { + + /** + * Spec: RTO14 + */ + internal fun fromInitialValue(objectType: ObjectType, initialValue: String, nonce: String, msTimeStamp: Long): ObjectId { + val valueForHash = "$initialValue:$nonce".toByteArray(StandardCharsets.UTF_8) + // RTO14b - Hash the initial value and nonce to create a unique identifier + val hashBytes = MessageDigest.getInstance("SHA-256").digest(valueForHash) + val urlSafeHash = Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes) + + return ObjectId(objectType, urlSafeHash, msTimeStamp) + } + /** * Creates ObjectId instance from hashed object id string. */ - fun fromString(objectId: String): ObjectId { + internal fun fromString(objectId: String): ObjectId { if (objectId.isEmpty()) { throw objectError("Invalid object id: $objectId") } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt new file mode 100644 index 000000000..d8d4a8eb5 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt @@ -0,0 +1,35 @@ +package io.ably.lib.objects + +import io.ably.lib.types.AblyException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.concurrent.Volatile + +/** + * ServerTime is a utility object that provides the current server time + * Spec: RTO16 + */ +internal object ServerTime { + @Volatile + private var serverTimeOffset: Long? = null + private val mutex = Mutex() + + /** + * Spec: RTO16a + */ + @Throws(AblyException::class) + internal suspend fun getCurrentTime(adapter: LiveObjectsAdapter): Long { + if (serverTimeOffset == null) { + mutex.withLock { + if (serverTimeOffset == null) { // Double-checked locking to ensure thread safety + val serverTime: Long = withContext(Dispatchers.IO) { adapter.time } + serverTimeOffset = serverTime - System.currentTimeMillis() + return serverTime + } + } + } + return System.currentTimeMillis() + serverTimeOffset!! + } +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt index 2fde867b9..b675ea239 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt @@ -4,6 +4,7 @@ import io.ably.lib.types.AblyException import io.ably.lib.types.ErrorInfo import io.ably.lib.util.Log import kotlinx.coroutines.* +import java.nio.charset.StandardCharsets import java.util.concurrent.CancellationException internal fun ablyException( @@ -40,13 +41,18 @@ internal fun serverError(errorMessage: String) = ablyException(errorMessage, Err internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyException { return ablyException(errorMessage, ErrorCode.InvalidObject, HttpStatusCode.InternalServerError, cause) } + +internal fun invalidInputError(errorMessage: String, cause: Throwable? = null): AblyException { + return ablyException(errorMessage, ErrorCode.InvalidInputParams, HttpStatusCode.InternalServerError, cause) +} + /** * Calculates the byte size of a string. * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference. * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4. */ internal val String.byteSize: Int - get() = this.toByteArray(Charsets.UTF_8).size + get() = this.toByteArray(StandardCharsets.UTF_8).size /** * A channel-specific coroutine scope for executing callbacks asynchronously in the LiveObjects system. @@ -101,3 +107,11 @@ internal class ObjectsAsyncScope(channelName: String) { scope.coroutineContext.cancelChildren(cause) } } + +/** + * Generates a random nonce string for object creation. + */ +internal fun generateNonce(): String { + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // avoid calculation using range + return (1..16).map { chars.random() }.joinToString("") +} 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 da55dbd2b..b999286cd 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 @@ -1,6 +1,5 @@ package io.ably.lib.objects.serialization -import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser import io.ably.lib.objects.* diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/BaseLiveObject.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/BaseLiveObject.kt index 4363ebb3b..1cb4df247 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/type/BaseLiveObject.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/BaseLiveObject.kt @@ -27,7 +27,7 @@ internal val LiveObjectUpdate.noOp get() = this.update == null */ internal abstract class BaseLiveObject( internal val objectId: String, // // RTLO3a - private val objectType: ObjectType, + internal val objectType: ObjectType, ) { protected open val tag = "BaseLiveObject" diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt index 73dd63d62..3dd03fcfe 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt @@ -12,6 +12,7 @@ import io.ably.lib.objects.type.counter.LiveCounterUpdate import io.ably.lib.objects.type.noOp import java.util.concurrent.atomic.AtomicReference import io.ably.lib.util.Log +import kotlinx.coroutines.runBlocking /** * Implementation of LiveObject for LiveCounter. @@ -38,21 +39,18 @@ internal class DefaultLiveCounter private constructor( private val channelName = liveObjects.channelName private val adapter: LiveObjectsAdapter get() = liveObjects.adapter + private val asyncScope get() = liveObjects.asyncScope - override fun increment() { - TODO("Not yet implemented") - } + override fun increment(amount: Number) = runBlocking { incrementAsync(amount.toDouble()) } - override fun incrementAsync(callback: ObjectsCallback) { - TODO("Not yet implemented") - } + override fun decrement(amount: Number) = runBlocking { incrementAsync(-amount.toDouble()) } - override fun decrement() { - TODO("Not yet implemented") + override fun incrementAsync(amount: Number, callback: ObjectsCallback) { + asyncScope.launchWithVoidCallback(callback) { incrementAsync(amount.toDouble()) } } - override fun decrementAsync(callback: ObjectsCallback) { - TODO("Not yet implemented") + override fun decrementAsync(amount: Number, callback: ObjectsCallback) { + asyncScope.launchWithVoidCallback(callback) { incrementAsync(-amount.toDouble()) } } override fun value(): Double { @@ -71,6 +69,28 @@ internal class DefaultLiveCounter private constructor( override fun validate(state: ObjectState) = liveCounterManager.validate(state) + private suspend fun incrementAsync(amount: Double) { + // RTLC12b, RTLC12c, RTLC12d - Validate write API configuration + adapter.throwIfInvalidWriteApiConfiguration(channelName) + + // RTLC12e1 - Validate input parameter + if (amount.isNaN() || amount.isInfinite()) { + throw invalidInputError("Counter value increment should be a valid number") + } + + // RTLC12e2, RTLC12e3, RTLC12e4 - Create ObjectMessage with the COUNTER_INC operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = objectId, + counterOp = ObjectCounterOp(amount = amount) + ) + ) + + // RTLC12f - Publish the message + liveObjects.publish(arrayOf(msg)) + } + override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveCounterUpdate { return liveCounterManager.applyState(objectState, message.serialTimestamp) } @@ -104,5 +124,15 @@ internal class DefaultLiveCounter private constructor( internal fun zeroValue(objectId: String, liveObjects: DefaultLiveObjects): DefaultLiveCounter { return DefaultLiveCounter(objectId, liveObjects) } + + /** + * Creates initial value operation for counter creation. + * Spec: RTO12f2 + */ + internal fun initialValue(count: Number): CounterCreatePayload { + return CounterCreatePayload( + counter = ObjectCounter(count = count.toDouble()) + ) + } } } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt index de1b16f5a..65079945a 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt @@ -11,12 +11,13 @@ import io.ably.lib.objects.type.ObjectType import io.ably.lib.objects.type.map.LiveMap import io.ably.lib.objects.type.map.LiveMapChange import io.ably.lib.objects.type.map.LiveMapUpdate +import io.ably.lib.objects.type.map.LiveMapValue import io.ably.lib.objects.type.noOp import io.ably.lib.util.Log +import kotlinx.coroutines.runBlocking import java.util.concurrent.ConcurrentHashMap import java.util.AbstractMap - /** * Implementation of LiveObject for LiveMap. * @@ -43,8 +44,9 @@ internal class DefaultLiveMap private constructor( private val channelName = liveObjects.channelName private val adapter: LiveObjectsAdapter get() = liveObjects.adapter internal val objectsPool: ObjectsPool get() = liveObjects.objectsPool + private val asyncScope get() = liveObjects.asyncScope - override fun get(keyName: String): Any? { + override fun get(keyName: String): LiveMapValue? { adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM5b, RTLM5c if (isTombstoned) { return null @@ -55,10 +57,10 @@ internal class DefaultLiveMap private constructor( return null // RTLM5d1 } - override fun entries(): Iterable> { + override fun entries(): Iterable> { adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM11b, RTLM11c - return sequence> { + return sequence> { for ((key, entry) in data.entries) { val value = entry.getResolvedValue(objectsPool) // RTLM11d, RTLM11d2 value?.let { @@ -77,7 +79,7 @@ internal class DefaultLiveMap private constructor( }.asIterable() } - override fun values(): Iterable { + override fun values(): Iterable { val iterableEntries = entries() return sequence { for (entry in iterableEntries) { @@ -91,20 +93,16 @@ internal class DefaultLiveMap private constructor( return data.values.count { !it.isEntryOrRefTombstoned(objectsPool) }.toLong() // RTLM10d } - override fun set(keyName: String, value: Any) { - TODO("Not yet implemented") - } + override fun set(keyName: String, value: LiveMapValue) = runBlocking { setAsync(keyName, value) } - override fun remove(keyName: String) { - TODO("Not yet implemented") - } + override fun remove(keyName: String) = runBlocking { removeAsync(keyName) } - override fun setAsync(keyName: String, value: Any, callback: ObjectsCallback) { - TODO("Not yet implemented") + override fun setAsync(keyName: String, value: LiveMapValue, callback: ObjectsCallback) { + asyncScope.launchWithVoidCallback(callback) { setAsync(keyName, value) } } override fun removeAsync(keyName: String, callback: ObjectsCallback) { - TODO("Not yet implemented") + asyncScope.launchWithVoidCallback(callback) { removeAsync(keyName) } } override fun validate(state: ObjectState) = liveMapManager.validate(state) @@ -118,6 +116,53 @@ internal class DefaultLiveMap private constructor( override fun unsubscribeAll() = liveMapManager.unsubscribeAll() + private suspend fun setAsync(keyName: String, value: LiveMapValue) { + // RTLM20b, RTLM20c, RTLM20d - Validate write API configuration + adapter.throwIfInvalidWriteApiConfiguration(channelName) + + // Validate input parameters + if (keyName.isEmpty()) { + throw invalidInputError("Map key should not be empty") + } + + // RTLM20e - Create ObjectMessage with the MAP_SET operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = objectId, + mapOp = ObjectMapOp( + key = keyName, + data = fromLiveMapValue(value) + ) + ) + ) + + // RTLM20f - Publish the message + liveObjects.publish(arrayOf(msg)) + } + + private suspend fun removeAsync(keyName: String) { + // RTLM21b, RTLM21cm RTLM21d - Validate write API configuration + adapter.throwIfInvalidWriteApiConfiguration(channelName) + + // Validate input parameter + if (keyName.isEmpty()) { + throw invalidInputError("Map key should not be empty") + } + + // RTLM21e - Create ObjectMessage with the MAP_REMOVE operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.MapRemove, + objectId = objectId, + mapOp = ObjectMapOp(key = keyName) + ) + ) + + // RTLM21f - Publish the message + liveObjects.publish(arrayOf(msg)) + } + override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveMapUpdate { return liveMapManager.applyState(objectState, message.serialTimestamp) } @@ -151,5 +196,55 @@ internal class DefaultLiveMap private constructor( internal fun zeroValue(objectId: String, objects: DefaultLiveObjects): DefaultLiveMap { return DefaultLiveMap(objectId, objects) } + + /** + * Creates an ObjectMap from map entries. + * Spec: RTO11f4 + */ + internal fun initialValue(entries: MutableMap): MapCreatePayload { + return MapCreatePayload( + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = entries.mapValues { (_, value) -> + ObjectMapEntry( + tombstone = false, + data = fromLiveMapValue(value) + ) + } + ) + ) + } + + /** + * Spec: RTLM20e5 + */ + private fun fromLiveMapValue(value: LiveMapValue): ObjectData { + return when { + value.isLiveMap || value.isLiveCounter -> { + ObjectData(objectId = (value.value as BaseLiveObject).objectId) + } + value.isBoolean -> { + ObjectData(value = ObjectValue.Boolean(value.asBoolean)) + } + value.isBinary -> { + ObjectData(value = ObjectValue.Binary(Binary(value.asBinary))) + } + value.isNumber -> { + ObjectData(value = ObjectValue.Number(value.asNumber)) + } + value.isString -> { + ObjectData(value = ObjectValue.String(value.asString)) + } + value.isJsonObject -> { + ObjectData(value = ObjectValue.JsonObject(value.asJsonObject)) + } + value.isJsonArray -> { + ObjectData(value = ObjectValue.JsonArray(value.asJsonArray)) + } + else -> { + throw IllegalArgumentException("Unsupported value type") + } + } + } } } diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt index bb0371183..3e24f30cb 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt @@ -1,8 +1,14 @@ package io.ably.lib.objects.type.livemap +import io.ably.lib.objects.* import io.ably.lib.objects.ObjectData import io.ably.lib.objects.ObjectsPool import io.ably.lib.objects.ObjectsPoolDefaults +import io.ably.lib.objects.type.BaseLiveObject +import io.ably.lib.objects.type.ObjectType +import io.ably.lib.objects.type.counter.LiveCounter +import io.ably.lib.objects.type.map.LiveMap +import io.ably.lib.objects.type.map.LiveMapValue /** * @spec RTLM3 - Map data structure storing entries @@ -36,17 +42,17 @@ internal fun LiveMapEntry.isEntryOrRefTombstoned(objectsPool: ObjectsPool): Bool * Returns value as is if object data stores a primitive type or * a reference to another LiveObject from the pool if it stores an objectId. */ -internal fun LiveMapEntry.getResolvedValue(objectsPool: ObjectsPool): Any? { +internal fun LiveMapEntry.getResolvedValue(objectsPool: ObjectsPool): LiveMapValue? { if (isTombstoned) { return null } // RTLM5d2a - data?.value?.let { return it.value } // RTLM5d2b, RTLM5d2c, RTLM5d2d, RTLM5d2e + data?.value?.let { return fromObjectValue(it) } // RTLM5d2b, RTLM5d2c, RTLM5d2d, RTLM5d2e data?.objectId?.let { refId -> // RTLM5d2f -has an objectId reference objectsPool.get(refId)?.let { refObject -> if (refObject.isTombstoned) { return null // tombstoned objects must not be surfaced to the end users } - return refObject // RTLM5d2f2 + return fromLiveObject(refObject) // RTLM5d2f2 } } return null // RTLM5d2g, RTLM5d2f1 @@ -59,3 +65,21 @@ internal fun LiveMapEntry.isEligibleForGc(): Boolean { val currentTime = System.currentTimeMillis() return isTombstoned && tombstonedAt?.let { currentTime - it >= ObjectsPoolDefaults.GC_GRACE_PERIOD_MS } == true } + +private fun fromObjectValue(objValue: ObjectValue): LiveMapValue { + return when (objValue) { + is ObjectValue.String -> LiveMapValue.of(objValue.value) + is ObjectValue.Number -> LiveMapValue.of(objValue.value) + is ObjectValue.Boolean -> LiveMapValue.of(objValue.value) + is ObjectValue.Binary -> LiveMapValue.of(objValue.value.data) + is ObjectValue.JsonObject -> LiveMapValue.of(objValue.value) + is ObjectValue.JsonArray -> LiveMapValue.of(objValue.value) + } +} + +private fun fromLiveObject(baseLiveObject: BaseLiveObject): LiveMapValue { + return when (baseLiveObject.objectType) { + ObjectType.Map -> LiveMapValue.of(baseLiveObject as LiveMap) + ObjectType.Counter -> LiveMapValue.of(baseLiveObject as LiveCounter) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt index bac176d3f..79a99de32 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt @@ -1,12 +1,11 @@ package io.ably.lib.objects.integration -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.map.LiveMap import io.ably.lib.objects.assertWaiter import io.ably.lib.objects.integration.helpers.ObjectId import io.ably.lib.objects.integration.helpers.fixtures.createUserEngagementMatrixMap import io.ably.lib.objects.integration.helpers.fixtures.createUserMapWithCountersObject import io.ably.lib.objects.integration.setup.IntegrationTest +import io.ably.lib.objects.type.map.LiveMapValue import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @@ -29,64 +28,64 @@ class DefaultLiveCounterTest: IntegrationTest() { val rootMap = channel.objects.root // Get the user map object from the root map - val userMap = rootMap.get("user") as LiveMap + val userMap = rootMap.get("user")?.asLiveMap assertNotNull(userMap, "User map should be synchronized") assertEquals(7L, userMap.size(), "User map should contain 7 top-level entries") // Assert direct counter objects at the top level of the user map // Test profileViews counter - should have initial value of 127 - val profileViewsCounter = userMap.get("profileViews") as LiveCounter + val profileViewsCounter = userMap.get("profileViews")?.asLiveCounter assertNotNull(profileViewsCounter, "Profile views counter should exist") assertEquals(127.0, profileViewsCounter.value(), "Profile views counter should have initial value of 127") // Test postLikes counter - should have initial value of 45 - val postLikesCounter = userMap.get("postLikes") as LiveCounter + val postLikesCounter = userMap.get("postLikes")?.asLiveCounter assertNotNull(postLikesCounter, "Post likes counter should exist") assertEquals(45.0, postLikesCounter.value(), "Post likes counter should have initial value of 45") // Test commentCount counter - should have initial value of 23 - val commentCountCounter = userMap.get("commentCount") as LiveCounter + val commentCountCounter = userMap.get("commentCount")?.asLiveCounter assertNotNull(commentCountCounter, "Comment count counter should exist") assertEquals(23.0, commentCountCounter.value(), "Comment count counter should have initial value of 23") // Test followingCount counter - should have initial value of 89 - val followingCountCounter = userMap.get("followingCount") as LiveCounter + val followingCountCounter = userMap.get("followingCount")?.asLiveCounter assertNotNull(followingCountCounter, "Following count counter should exist") assertEquals(89.0, followingCountCounter.value(), "Following count counter should have initial value of 89") // Test followersCount counter - should have initial value of 156 - val followersCountCounter = userMap.get("followersCount") as LiveCounter + val followersCountCounter = userMap.get("followersCount")?.asLiveCounter assertNotNull(followersCountCounter, "Followers count counter should exist") assertEquals(156.0, followersCountCounter.value(), "Followers count counter should have initial value of 156") // Test loginStreak counter - should have initial value of 7 - val loginStreakCounter = userMap.get("loginStreak") as LiveCounter + val loginStreakCounter = userMap.get("loginStreak")?.asLiveCounter assertNotNull(loginStreakCounter, "Login streak counter should exist") assertEquals(7.0, loginStreakCounter.value(), "Login streak counter should have initial value of 7") // Assert the nested engagement metrics map - val engagementMetrics = userMap.get("engagementMetrics") as LiveMap + val engagementMetrics = userMap.get("engagementMetrics")?.asLiveMap assertNotNull(engagementMetrics, "Engagement metrics map should exist") assertEquals(4L, engagementMetrics.size(), "Engagement metrics map should contain 4 counter entries") // Assert counter objects within the engagement metrics map // Test totalShares counter - should have initial value of 34 - val totalSharesCounter = engagementMetrics.get("totalShares") as LiveCounter + val totalSharesCounter = engagementMetrics.get("totalShares")?.asLiveCounter assertNotNull(totalSharesCounter, "Total shares counter should exist") assertEquals(34.0, totalSharesCounter.value(), "Total shares counter should have initial value of 34") // Test totalBookmarks counter - should have initial value of 67 - val totalBookmarksCounter = engagementMetrics.get("totalBookmarks") as LiveCounter + val totalBookmarksCounter = engagementMetrics.get("totalBookmarks")?.asLiveCounter assertNotNull(totalBookmarksCounter, "Total bookmarks counter should exist") assertEquals(67.0, totalBookmarksCounter.value(), "Total bookmarks counter should have initial value of 67") // Test totalReactions counter - should have initial value of 189 - val totalReactionsCounter = engagementMetrics.get("totalReactions") as LiveCounter + val totalReactionsCounter = engagementMetrics.get("totalReactions")?.asLiveCounter assertNotNull(totalReactionsCounter, "Total reactions counter should exist") assertEquals(189.0, totalReactionsCounter.value(), "Total reactions counter should have initial value of 189") // Test dailyActiveStreak counter - should have initial value of 12 - val dailyActiveStreakCounter = engagementMetrics.get("dailyActiveStreak") as LiveCounter + val dailyActiveStreakCounter = engagementMetrics.get("dailyActiveStreak")?.asLiveCounter assertNotNull(dailyActiveStreakCounter, "Daily active streak counter should exist") assertEquals(12.0, dailyActiveStreakCounter.value(), "Daily active streak counter should have initial value of 12") @@ -130,7 +129,7 @@ class DefaultLiveCounterTest: IntegrationTest() { assertWaiter { rootMap.get("testCounter") != null } // Assert initial state after creation - val testCounter = rootMap.get("testCounter") as LiveCounter + val testCounter = rootMap.get("testCounter")?.asLiveCounter assertNotNull(testCounter, "Test counter should be created and accessible") assertEquals(10.0, testCounter.value(), "Counter should have initial value of 10") @@ -201,7 +200,98 @@ class DefaultLiveCounterTest: IntegrationTest() { assertNotNull(testCounter, "Counter should still be accessible at the end") // Verify we can still access it from the root map - val finalCounterCheck = rootMap.get("testCounter") as LiveCounter + val finalCounterCheck = rootMap.get("testCounter")?.asLiveCounter + assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") + assertEquals(30.0, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") + } + + @Test + fun testLiveCounterOperationsUsingRealtime() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val objects = channel.objects + val rootMap = channel.objects.root + + // Step 1: Create a new counter with initial value of 10 + val testCounterObject = objects.createCounter( 10.0) + rootMap.set("testCounter", LiveMapValue.of(testCounterObject)) + + // Wait for updated testCounter to be available in the root map + assertWaiter { rootMap.get("testCounter") != null } + + // Assert initial state after creation + val testCounter = rootMap.get("testCounter")?.asLiveCounter + assertNotNull(testCounter, "Test counter should be created and accessible") + assertEquals(10.0, testCounter.value(), "Counter should have initial value of 10") + + // Step 2: Increment counter by 5 (10 + 5 = 15) + testCounter.increment(5.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 15.0 } + + // Assert after first increment + assertEquals(15.0, testCounter.value(), "Counter should be incremented to 15") + + // Step 3: Increment counter by 3 (15 + 3 = 18) + testCounter.increment(3.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 18.0 } + + // Assert after second increment + assertEquals(18.0, testCounter.value(), "Counter should be incremented to 18") + + // Step 4: Increment counter by a larger amount: 12 (18 + 12 = 30) + testCounter.increment(12.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 30.0 } + + // Assert after third increment + assertEquals(30.0, testCounter.value(), "Counter should be incremented to 30") + + // Step 5: Decrement counter by 7 (30 - 7 = 23) + testCounter.decrement(7.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 23.0 } + + // Assert after first decrement + assertEquals(23.0, testCounter.value(), "Counter should be decremented to 23") + + // Step 6: Decrement counter by 4 (23 - 4 = 19) + testCounter.decrement(4.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 19.0 } + + // Assert after second decrement + assertEquals(19.0, testCounter.value(), "Counter should be decremented to 19") + + // Step 7: Increment counter by 1 (19 + 1 = 20) + testCounter.increment(1.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 20.0 } + + // Assert after final increment + assertEquals(20.0, testCounter.value(), "Counter should be incremented to 20") + + // Step 8: Decrement counter by a larger amount: 15 (20 - 15 = 5) + testCounter.decrement(15.0) + // Wait for the counter to be updated + assertWaiter { testCounter.value() == 5.0 } + + // Assert after large decrement + assertEquals(5.0, testCounter.value(), "Counter should be decremented to 5") + + // Final verification - test final increment to ensure counter still works + testCounter.increment(25.0) + assertWaiter { testCounter.value() == 30.0 } + + // Assert final state + assertEquals(30.0, testCounter.value(), "Counter should have final value of 30") + + // Verify the counter object is still accessible and functioning + assertNotNull(testCounter, "Counter should still be accessible at the end") + + // Verify we can still access it from the root map + val finalCounterCheck = rootMap.get("testCounter")?.asLiveCounter assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") assertEquals(30.0, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") } @@ -215,11 +305,11 @@ class DefaultLiveCounterTest: IntegrationTest() { val channel = getRealtimeChannel(channelName) val rootMap = channel.objects.root - val userEngagementMap = rootMap.get("userMatrix") as LiveMap - assertEquals(4L, userEngagementMap.size(), "User engagement map should contain 4 top-level entries") + val userEngagementMap = rootMap.get("userMatrix")?.asLiveMap + assertEquals(4L, userEngagementMap!!.size(), "User engagement map should contain 4 top-level entries") - val totalReactions = userEngagementMap.get("totalReactions") as LiveCounter - assertEquals(189.0, totalReactions.value(), "Total reactions counter should have initial value of 189") + val totalReactions = userEngagementMap.get("totalReactions")?.asLiveCounter + assertEquals(189.0, totalReactions!!.value(), "Total reactions counter should have initial value of 189") // Subscribe to changes on the totalReactions counter val counterUpdates = mutableListOf() diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt index 98f167521..e3043abc1 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt @@ -6,9 +6,8 @@ import io.ably.lib.objects.ObjectValue import io.ably.lib.objects.integration.helpers.fixtures.createUserMapObject import io.ably.lib.objects.integration.helpers.fixtures.createUserProfileMapObject import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.map.LiveMap import io.ably.lib.objects.type.map.LiveMapUpdate +import io.ably.lib.objects.type.map.LiveMapValue import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @@ -32,71 +31,71 @@ class DefaultLiveMapTest: IntegrationTest() { val rootMap = channel.objects.root // Get the user map object from the root map - val userMap = rootMap.get("user") as LiveMap + val userMap = rootMap.get("user")?.asLiveMap assertNotNull(userMap, "User map should be synchronized") assertEquals(5L, userMap.size(), "User map should contain 5 top-level entries") // Assert Counter Objects // Test loginCounter - should have initial value of 5 - val loginCounter = userMap.get("loginCounter") as LiveCounter + val loginCounter = userMap.get("loginCounter")?.asLiveCounter assertNotNull(loginCounter, "Login counter should exist") assertEquals(5.0, loginCounter.value(), "Login counter should have initial value of 5") // Test sessionCounter - should have initial value of 0 - val sessionCounter = userMap.get("sessionCounter") as LiveCounter + val sessionCounter = userMap.get("sessionCounter")?.asLiveCounter assertNotNull(sessionCounter, "Session counter should exist") assertEquals(0.0, sessionCounter.value(), "Session counter should have initial value of 0") // Assert User Profile Map - val userProfile = userMap.get("userProfile") as LiveMap + val userProfile = userMap.get("userProfile")?.asLiveMap assertNotNull(userProfile, "User profile map should exist") assertEquals(6L, userProfile.size(), "User profile should contain 6 entries") // Assert user profile primitive values - assertEquals("user123", userProfile.get("userId"), "User ID should match expected value") - assertEquals("John Doe", userProfile.get("name"), "User name should match expected value") - assertEquals("john@example.com", userProfile.get("email"), "User email should match expected value") - assertEquals(true, userProfile.get("isActive"), "User should be active") + assertEquals("user123", userProfile.get("userId")?.asString, "User ID should match expected value") + assertEquals("John Doe", userProfile.get("name")?.asString, "User name should match expected value") + assertEquals("john@example.com", userProfile.get("email")?.asString, "User email should match expected value") + assertEquals(true, userProfile.get("isActive")?.asBoolean, "User should be active") // Assert Preferences Map (nested within user profile) - val preferences = userProfile.get("preferences") as LiveMap + val preferences = userProfile.get("preferences")?.asLiveMap assertNotNull(preferences, "Preferences map should exist") assertEquals(4L, preferences.size(), "Preferences should contain 4 entries") - assertEquals("dark", preferences.get("theme"), "Theme preference should be dark") - assertEquals(true, preferences.get("notifications"), "Notifications should be enabled") - assertEquals("en", preferences.get("language"), "Language should be English") - assertEquals(3.0, preferences.get("maxRetries"), "Max retries should be 3") + assertEquals("dark", preferences.get("theme")?.asString, "Theme preference should be dark") + assertEquals(true, preferences.get("notifications")?.asBoolean, "Notifications should be enabled") + assertEquals("en", preferences.get("language")?.asString, "Language should be English") + assertEquals(3.0, preferences.get("maxRetries")?.asNumber, "Max retries should be 3") // Assert Metrics Map (nested within user profile) - val metrics = userProfile.get("metrics") as LiveMap + val metrics = userProfile.get("metrics")?.asLiveMap assertNotNull(metrics, "Metrics map should exist") assertEquals(4L, metrics.size(), "Metrics should contain 4 entries") - assertEquals("2024-01-01T08:30:00Z", metrics.get("lastLoginTime"), "Last login time should match") - assertEquals(42.0, metrics.get("profileViews"), "Profile views should be 42") + assertEquals("2024-01-01T08:30:00Z", metrics.get("lastLoginTime")?.asString, "Last login time should match") + assertEquals(42.0, metrics.get("profileViews")?.asNumber, "Profile views should be 42") // Test counter references within metrics map - val totalLoginsCounter = metrics.get("totalLogins") as LiveCounter + val totalLoginsCounter = metrics.get("totalLogins")?.asLiveCounter assertNotNull(totalLoginsCounter, "Total logins counter should exist") assertEquals(5.0, totalLoginsCounter.value(), "Total logins should reference login counter with value 5") - val activeSessionsCounter = metrics.get("activeSessions") as LiveCounter + val activeSessionsCounter = metrics.get("activeSessions")?.asLiveCounter assertNotNull(activeSessionsCounter, "Active sessions counter should exist") assertEquals(0.0, activeSessionsCounter.value(), "Active sessions should reference session counter with value 0") // Assert direct references to maps from top-level user map - val preferencesMapRef = userMap.get("preferencesMap") as LiveMap + val preferencesMapRef = userMap.get("preferencesMap")?.asLiveMap assertNotNull(preferencesMapRef, "Preferences map reference should exist") assertEquals(4L, preferencesMapRef.size(), "Referenced preferences map should have 4 entries") - assertEquals("dark", preferencesMapRef.get("theme"), "Referenced preferences should match nested preferences") + assertEquals("dark", preferencesMapRef.get("theme")?.asString, "Referenced preferences should match nested preferences") - val metricsMapRef = userMap.get("metricsMap") as LiveMap + val metricsMapRef = userMap.get("metricsMap")?.asLiveMap assertNotNull(metricsMapRef, "Metrics map reference should exist") assertEquals(4L, metricsMapRef.size(), "Referenced metrics map should have 4 entries") - assertEquals("2024-01-01T08:30:00Z", metricsMapRef.get("lastLoginTime"), "Referenced metrics should match nested metrics") + assertEquals("2024-01-01T08:30:00Z", metricsMapRef.get("lastLoginTime")?.asString, "Referenced metrics should match nested metrics") // Verify that references point to the same objects - assertEquals(preferences.get("theme"), preferencesMapRef.get("theme"), "Preference references should point to same data") - assertEquals(metrics.get("profileViews"), metricsMapRef.get("profileViews"), "Metrics references should point to same data") + assertEquals(preferences.get("theme")?.asString, preferencesMapRef.get("theme")?.asString, "Preference references should point to same data") + assertEquals(metrics.get("profileViews")?.asNumber, metricsMapRef.get("profileViews")?.asNumber, "Metrics references should point to same data") } /** @@ -124,61 +123,61 @@ class DefaultLiveMapTest: IntegrationTest() { assertWaiter { rootMap.get("testMap") != null } // Assert initial state after creation - val testMap = rootMap.get("testMap") as LiveMap + val testMap = rootMap.get("testMap")?.asLiveMap assertNotNull(testMap, "Test map should be created and accessible") assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") - assertEquals("Alice", testMap.get("name"), "Initial name should be Alice") - assertEquals(30.0, testMap.get("age"), "Initial age should be 30") - assertEquals(true, testMap.get("isActive"), "Initial active status should be true") + assertEquals("Alice", testMap.get("name")?.asString, "Initial name should be Alice") + assertEquals(30.0, testMap.get("age")?.asNumber, "Initial age should be 30") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Initial active status should be true") // Step 2: Update an existing field (name from "Alice" to "Bob") restObjects.setMapValue(channelName, testMapObjectId, "name", ObjectValue.String("Bob")) // Wait for the map to be updated - assertWaiter { testMap.get("name") == "Bob" } + assertWaiter { testMap.get("name")?.asString == "Bob" } // Assert after updating existing field assertEquals(3L, testMap.size(), "Map size should remain the same after update") - assertEquals("Bob", testMap.get("name"), "Name should be updated to Bob") - assertEquals(30.0, testMap.get("age"), "Age should remain unchanged") - assertEquals(true, testMap.get("isActive"), "Active status should remain unchanged") + assertEquals("Bob", testMap.get("name")?.asString, "Name should be updated to Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") // Step 3: Add a new field (email) restObjects.setMapValue(channelName, testMapObjectId, "email", ObjectValue.String("bob@example.com")) // Wait for the map to be updated - assertWaiter { testMap.get("email") == "bob@example.com" } + assertWaiter { testMap.get("email")?.asString == "bob@example.com" } // Assert after adding new field assertEquals(4L, testMap.size(), "Map size should increase after adding new field") - assertEquals("Bob", testMap.get("name"), "Name should remain Bob") - assertEquals(30.0, testMap.get("age"), "Age should remain unchanged") - assertEquals(true, testMap.get("isActive"), "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email"), "Email should be added successfully") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should be added successfully") // Step 4: Add another new field with different data type (score as number) restObjects.setMapValue(channelName, testMapObjectId, "score", ObjectValue.Number(85)) // Wait for the map to be updated - assertWaiter { testMap.get("score") == 85.0 } + assertWaiter { testMap.get("score")?.asNumber == 85.0 } // Assert after adding second new field assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") - assertEquals("Bob", testMap.get("name"), "Name should remain Bob") - assertEquals(30.0, testMap.get("age"), "Age should remain unchanged") - assertEquals(true, testMap.get("isActive"), "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email"), "Email should remain unchanged") - assertEquals(85.0, testMap.get("score"), "Score should be added as numeric value") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should be added as numeric value") // Step 5: Update the boolean field restObjects.setMapValue(channelName, testMapObjectId, "isActive", ObjectValue.Boolean(false)) // Wait for the map to be updated - assertWaiter { testMap.get("isActive") == false } + assertWaiter { testMap.get("isActive")?.asBoolean == false } // Assert after updating boolean field assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") - assertEquals("Bob", testMap.get("name"), "Name should remain Bob") - assertEquals(30.0, testMap.get("age"), "Age should remain unchanged") - assertEquals(false, testMap.get("isActive"), "Active status should be updated to false") - assertEquals("bob@example.com", testMap.get("email"), "Email should remain unchanged") - assertEquals(85.0, testMap.get("score"), "Score should remain unchanged") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should be updated to false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") // Step 6: Remove a field (age) restObjects.removeMapValue(channelName, testMapObjectId, "age") @@ -187,11 +186,11 @@ class DefaultLiveMapTest: IntegrationTest() { // Assert after removing field assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") - assertEquals("Bob", testMap.get("name"), "Name should remain Bob") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") assertNull(testMap.get("age"), "Age should be removed and return null") - assertEquals(false, testMap.get("isActive"), "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email"), "Email should remain unchanged") - assertEquals(85.0, testMap.get("score"), "Score should remain unchanged") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") // Step 7: Remove another field (score) restObjects.removeMapValue(channelName, testMapObjectId, "score") @@ -200,9 +199,9 @@ class DefaultLiveMapTest: IntegrationTest() { // Assert final state after second removal assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") - assertEquals("Bob", testMap.get("name"), "Name should remain Bob") - assertEquals(false, testMap.get("isActive"), "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email"), "Email should remain unchanged") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") assertNull(testMap.get("score"), "Score should be removed and return null") assertNull(testMap.get("age"), "Age should remain null") @@ -212,8 +211,125 @@ class DefaultLiveMapTest: IntegrationTest() { val finalKeys = testMap.keys().toSet() assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") - val finalValues = testMap.values().toSet() - assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final values should match expected set") + val finalValues = testMap.values().map { it.value }.toSet() + assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final string values should match expected set") + } + + /** + * Tests sequential map operations including creation with initial data, updating existing fields, + * adding new fields, and removing fields. Validates the resulting data after each operation. + */ + @Test + fun testLiveMapOperationsUsingRealtime() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val objects = channel.objects + val rootMap = channel.objects.root + + // Step 1: Create a new map with initial data + val testMapObject = objects.createMap( + mapOf( + "name" to LiveMapValue.of("Alice"), + "age" to LiveMapValue.of(30), + "isActive" to LiveMapValue.of(true), + ) + ) + rootMap.set("testMap", LiveMapValue.of(testMapObject)) + + // wait for updated testMap to be available in the root map + assertWaiter { rootMap.get("testMap") != null } + + // Assert initial state after creation + val testMap = rootMap.get("testMap")?.asLiveMap + assertNotNull(testMap, "Test map should be created and accessible") + assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") + assertEquals("Alice", testMap.get("name")?.asString, "Initial name should be Alice") + assertEquals(30.0, testMap.get("age")?.asNumber, "Initial age should be 30") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Initial active status should be true") + + // Step 2: Update an existing field (name from "Alice" to "Bob") + testMap.set("name", LiveMapValue.of("Bob")) + // Wait for the map to be updated + assertWaiter { testMap.get("name")?.asString == "Bob" } + + // Assert after updating existing field + assertEquals(3L, testMap.size(), "Map size should remain the same after update") + assertEquals("Bob", testMap.get("name")?.asString, "Name should be updated to Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + + // Step 3: Add a new field (email) + testMap.set("email", LiveMapValue.of("bob@example.com")) + // Wait for the map to be updated + assertWaiter { testMap.get("email")?.asString == "bob@example.com" } + + // Assert after adding new field + assertEquals(4L, testMap.size(), "Map size should increase after adding new field") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should be added successfully") + + // Step 4: Add another new field with different data type (score as number) + testMap.set("score", LiveMapValue.of(85)) + // Wait for the map to be updated + assertWaiter { testMap.get("score")?.asNumber == 85.0 } + + // Assert after adding second new field + assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should be added as numeric value") + + // Step 5: Update the boolean field + testMap.set("isActive", LiveMapValue.of(false)) + // Wait for the map to be updated + assertWaiter { testMap.get("isActive")?.asBoolean == false } + + // Assert after updating boolean field + assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should be updated to false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") + + // Step 6: Remove a field (age) + testMap.remove("age") + // Wait for the map to be updated + assertWaiter { testMap.get("age") == null } + + // Assert after removing field + assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertNull(testMap.get("age"), "Age should be removed and return null") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") + + // Step 7: Remove another field (score) + testMap.remove("score") + // Wait for the map to be updated + assertWaiter { testMap.get("score") == null } + + // Assert final state after second removal + assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") + assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") + assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") + assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") + assertNull(testMap.get("score"), "Score should be removed and return null") + assertNull(testMap.get("age"), "Age should remain null") + + // Final verification - ensure all expected keys exist and unwanted keys don't + assertEquals(3, testMap.size(), "Final map should have exactly 3 entries") + + val finalKeys = testMap.keys().toSet() + assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") + + val finalValues = testMap.values().map { it.value }.toSet() + assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final string values should match expected set") } @Test @@ -226,15 +342,15 @@ class DefaultLiveMapTest: IntegrationTest() { val rootMap = channel.objects.root // Get the user profile map object from the root map - val userProfile = rootMap.get("userProfile") as LiveMap + val userProfile = rootMap.get("userProfile")?.asLiveMap assertNotNull(userProfile, "User profile should be synchronized") assertEquals(4L, userProfile.size(), "User profile should contain 4 entries") // Verify initial values - assertEquals("user123", userProfile.get("userId"), "Initial userId should be user123") - assertEquals("John Doe", userProfile.get("name"), "Initial name should be John Doe") - assertEquals("john@example.com", userProfile.get("email"), "Initial email should be john@example.com") - assertEquals(true, userProfile.get("isActive"), "Initial isActive should be true") + assertEquals("user123", userProfile.get("userId")?.asString, "Initial userId should be user123") + assertEquals("John Doe", userProfile.get("name")?.asString, "Initial name should be John Doe") + assertEquals("john@example.com", userProfile.get("email")?.asString, "Initial email should be john@example.com") + assertEquals(true, userProfile.get("isActive")?.asBoolean, "Initial isActive should be true") // Subscribe to changes in the user profile map val userProfileUpdates = mutableListOf() @@ -254,7 +370,7 @@ class DefaultLiveMapTest: IntegrationTest() { assertEquals(LiveMapUpdate.Change.UPDATED, firstUpdateMap["name"], "name should be marked as UPDATED") // Verify the value was actually updated - assertEquals("Bob Smith", userProfile.get("name"), "Name should be updated to Bob Smith") + assertEquals("Bob Smith", userProfile.get("name")?.asString, "Name should be updated to Bob Smith") // Step 2: Update another field in the user profile map (change the email) userProfileUpdates.clear() @@ -271,7 +387,7 @@ class DefaultLiveMapTest: IntegrationTest() { assertEquals(LiveMapUpdate.Change.UPDATED, secondUpdateMap["email"], "email should be marked as UPDATED") // Verify the value was actually updated - assertEquals("bob@example.com", userProfile.get("email"), "Email should be updated to bob@example.com") + assertEquals("bob@example.com", userProfile.get("email")?.asString, "Email should be updated to bob@example.com") // Step 3: Remove an existing field from the user profile map (remove isActive) userProfileUpdates.clear() @@ -289,9 +405,9 @@ class DefaultLiveMapTest: IntegrationTest() { // Verify final state of the user profile map assertEquals(3L, userProfile.size(), "User profile should have 3 entries after removing isActive") - assertEquals("user123", userProfile.get("userId"), "userId should remain unchanged") - assertEquals("Bob Smith", userProfile.get("name"), "name should remain updated") - assertEquals("bob@example.com", userProfile.get("email"), "email should remain updated") + assertEquals("user123", userProfile.get("userId")?.asString, "userId should remain unchanged") + assertEquals("Bob Smith", userProfile.get("name")?.asString, "name should remain updated") + assertEquals("bob@example.com", userProfile.get("email")?.asString, "email should remain updated") assertNull(userProfile.get("isActive"), "isActive should be removed") // Clean up subscription diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt index b3a6f5d95..0ab337eb8 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveObjectsTest.kt @@ -1,19 +1,13 @@ package io.ably.lib.objects.integration -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.integration.helpers.State import io.ably.lib.objects.integration.helpers.fixtures.initializeRootMap import io.ably.lib.objects.integration.helpers.simulateObjectDelete import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.size import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.type.counter.LiveCounter import io.ably.lib.objects.type.livecounter.DefaultLiveCounter import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.map.LiveMap import io.ably.lib.objects.type.map.LiveMapUpdate import kotlinx.coroutines.test.runTest import org.junit.Test @@ -79,70 +73,70 @@ class DefaultLiveObjectsTest : IntegrationTest() { // Assert Counter Objects // Test emptyCounter - should have initial value of 0 - val emptyCounter = rootMap.get("emptyCounter") as LiveCounter + val emptyCounter = rootMap.get("emptyCounter")?.asLiveCounter assertNotNull(emptyCounter) assertEquals(0.0, emptyCounter.value()) // Test initialValueCounter - should have initial value of 10 - val initialValueCounter = rootMap.get("initialValueCounter") as LiveCounter + val initialValueCounter = rootMap.get("initialValueCounter")?.asLiveCounter assertNotNull(initialValueCounter) assertEquals(10.0, initialValueCounter.value()) // Test referencedCounter - should have initial value of 20 - val referencedCounter = rootMap.get("referencedCounter") as LiveCounter + val referencedCounter = rootMap.get("referencedCounter")?.asLiveCounter assertNotNull(referencedCounter) assertEquals(20.0, referencedCounter.value()) // Assert Map Objects // Test emptyMap - should be an empty map - val emptyMap = rootMap.get("emptyMap") as LiveMap + val emptyMap = rootMap.get("emptyMap")?.asLiveMap assertNotNull(emptyMap) assertEquals(0L, emptyMap.size()) // Test referencedMap - should contain one key "counterKey" pointing to referencedCounter - val referencedMap = rootMap.get("referencedMap") as LiveMap + val referencedMap = rootMap.get("referencedMap")?.asLiveMap assertNotNull(referencedMap) assertEquals(1L, referencedMap.size()) - val referencedMapCounter = referencedMap.get("counterKey") as LiveCounter + val referencedMapCounter = referencedMap.get("counterKey")?.asLiveCounter assertNotNull(referencedMapCounter) assertEquals(20.0, referencedMapCounter.value()) // Should point to the same counter with value 20 // Test valuesMap - should contain all primitive data types and one map reference - val valuesMap = rootMap.get("valuesMap") as LiveMap + val valuesMap = rootMap.get("valuesMap")?.asLiveMap assertNotNull(valuesMap) assertEquals(13L, valuesMap.size()) // Should have 13 entries // Assert string values - assertEquals("stringValue", valuesMap.get("string")) - assertEquals("", valuesMap.get("emptyString")) + assertEquals("stringValue", valuesMap.get("string")?.asString) + assertEquals("", valuesMap.get("emptyString")?.asString) // Assert binary values - val bytesValue = valuesMap.get("bytes") as Binary + val bytesValue = valuesMap.get("bytes")?.asBinary assertNotNull(bytesValue) - val expectedBinary = Binary("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray()) - assertEquals(expectedBinary, bytesValue) // Should contain encoded JSON data + val expectedBinary = "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray() + assertEquals(expectedBinary.contentEquals(bytesValue), true) // Should contain encoded JSON data - val emptyBytesValue = valuesMap.get("emptyBytes") as Binary + val emptyBytesValue = valuesMap.get("emptyBytes")?.asBinary assertNotNull(emptyBytesValue) - assertEquals(0, emptyBytesValue.size()) // Should be empty byte array + assertEquals(0, emptyBytesValue.size) // Should be empty byte array // Assert numeric values - assertEquals(99999999.0, valuesMap.get("maxSafeNumber")) - assertEquals(-99999999.0, valuesMap.get("negativeMaxSafeNumber")) - assertEquals(1.0, valuesMap.get("number")) - assertEquals(0.0, valuesMap.get("zero")) + assertEquals(99999999.0, valuesMap.get("maxSafeNumber")?.asNumber) + assertEquals(-99999999.0, valuesMap.get("negativeMaxSafeNumber")?.asNumber) + assertEquals(1.0, valuesMap.get("number")?.asNumber) + assertEquals(0.0, valuesMap.get("zero")?.asNumber) // Assert boolean values - assertEquals(true, valuesMap.get("true")) - assertEquals(false, valuesMap.get("false")) + assertEquals(true, valuesMap.get("true")?.asBoolean) + assertEquals(false, valuesMap.get("false")?.asBoolean) // Assert JSON object value - should contain {"foo": "bar"} - val jsonObjectValue = valuesMap.get("object") as JsonObject + val jsonObjectValue = valuesMap.get("object")?.asJsonObject assertNotNull(jsonObjectValue) assertEquals("bar", jsonObjectValue.get("foo").asString) // Assert JSON array value - should contain ["foo", "bar", "baz"] - val jsonArrayValue = valuesMap.get("array") as JsonArray + val jsonArrayValue = valuesMap.get("array")?.asJsonArray assertNotNull(jsonArrayValue) assertEquals(3, jsonArrayValue.size()) assertEquals("foo", jsonArrayValue[0].asString) @@ -150,10 +144,10 @@ class DefaultLiveObjectsTest : IntegrationTest() { assertEquals("baz", jsonArrayValue[2].asString) // Assert map reference - should point to the same referencedMap - val mapRefValue = valuesMap.get("mapRef") as LiveMap + val mapRefValue = valuesMap.get("mapRef")?.asLiveMap assertNotNull(mapRefValue) assertEquals(1L, mapRefValue.size()) - val mapRefCounter = mapRefValue.get("counterKey") as LiveCounter + val mapRefCounter = mapRefValue.get("counterKey")?.asLiveCounter assertNotNull(mapRefCounter) assertEquals(20.0, mapRefCounter.value()) // Should point to the same counter with value 20 } @@ -176,7 +170,7 @@ class DefaultLiveObjectsTest : IntegrationTest() { assertEquals(6L, rootMap.size()) // Should have 6 entries initially // Remove the "referencedCounter" from the root map - val refCounter = rootMap.get("referencedCounter") as LiveCounter + val refCounter = rootMap.get("referencedCounter")?.asLiveCounter assertNotNull(refCounter) // Subscribe to counter updates to verify removal val counterUpdates = mutableListOf() @@ -193,7 +187,7 @@ class DefaultLiveObjectsTest : IntegrationTest() { assertEquals(-20.0, counterUpdates[0]) // The update should indicate counter was removed with value 20 // Remove the "referencedMap" from the root map - val referencedMap = rootMap.get("referencedMap") as LiveMap + val referencedMap = rootMap.get("referencedMap")?.asLiveMap assertNotNull(referencedMap) // Subscribe to map updates to verify removal val mapUpdates = mutableListOf>() @@ -214,7 +208,7 @@ class DefaultLiveObjectsTest : IntegrationTest() { assertEquals(LiveMapUpdate.Change.REMOVED, updatedMap.values.first()) // Should indicate removal // Remove the "valuesMap" from the root map - val valuesMap = rootMap.get("valuesMap") as LiveMap + val valuesMap = rootMap.get("valuesMap")?.asLiveMap assertNotNull(valuesMap) // Subscribe to map updates to verify removal val valuesMapUpdates = mutableListOf>() diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt index 2a8b466ee..283d11a4f 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt @@ -3,8 +3,8 @@ package io.ably.lib.objects.integration.helpers import com.google.gson.JsonObject import io.ably.lib.objects.ObjectData import io.ably.lib.objects.ObjectOperationAction +import io.ably.lib.objects.generateNonce import io.ably.lib.objects.serialization.gson -import java.util.* internal object PayloadBuilder { /** @@ -19,11 +19,6 @@ internal object PayloadBuilder { ObjectOperationAction.CounterInc to "COUNTER_INC", ) - /** - * Generates a random nonce string for object creation operations. - */ - private fun nonce(): String = UUID.randomUUID().toString().replace("-", "") - /** * Creates a MAP_CREATE operation payload for REST API. * @@ -46,7 +41,7 @@ internal object PayloadBuilder { if (objectId != null) { opBody.addProperty("objectId", objectId) - opBody.addProperty("nonce", nonce ?: nonce()) + opBody.addProperty("nonce", nonce ?: generateNonce()) } return opBody @@ -113,7 +108,7 @@ internal object PayloadBuilder { if (objectId != null) { opBody.addProperty("objectId", objectId) - opBody.addProperty("nonce", nonce ?: nonce()) + opBody.addProperty("nonce", nonce ?: generateNonce()) } return opBody diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt index 5723c5293..d8eaaf697 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt @@ -52,4 +52,24 @@ class ObjectIdTest { assertEquals(92_000, exception.errorInfo?.code) assertEquals(500, exception.errorInfo?.statusCode) } + + @Test + fun testFromInitialValue() { + val objectType = ObjectType.Map + val initialValue = "test-value" + val nonce = "test-nonce" + val msTimestamp = 1640995200000L + + val objectId = ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp) + // Verify the string format follows the expected pattern: type:hash@timestamp + val objectIdString = objectId.toString() + assertTrue(objectIdString.startsWith("map:")) + assertTrue(objectIdString.contains("@")) + assertTrue(objectIdString.endsWith(msTimestamp.toString())) + + val expectedHash = "GSjv-adTaJPL8-382qF3JuIyE4TCc6QKIIqb577pz00" + // Verify the hash value matches expected + val hashPart = objectIdString.substring(4, objectIdString.indexOf("@")) + assertEquals(expectedHash, hashPart) + } } 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 295e54096..57ddde1e8 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 @@ -168,7 +168,7 @@ class ObjectMessageSizeTest { } // Assert on error code and message assertEquals(40009, exception.errorInfo.code) - val expectedMessage = "ObjectMessage size 66560 exceeds maximum allowed size of 65536 bytes" + val expectedMessage = "ObjectMessages size 66560 exceeds maximum allowed size of 65536 bytes" assertEquals(expectedMessage, exception.errorInfo.message) } } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt index cbf4dbaee..4fbc63bc6 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt @@ -12,6 +12,24 @@ import java.util.concurrent.CancellationException class UtilsTest { + @Test + fun testGenerateNonce() { + // Test basic functionality + val nonce1 = generateNonce() + val nonce2 = generateNonce() + + assertEquals(16, nonce1.length) + assertEquals(16, nonce2.length) + assertNotEquals(nonce1, nonce2) // Should be random + + // Test character set + val validChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + val nonce = generateNonce() + nonce.forEach { char -> + assertTrue("Nonce should only contain valid characters", validChars.contains(char)) + } + } + @Test fun testStringByteSize() { // Test ASCII strings