From 6bfc6ea466e589b7506d28275afd4851581453c2 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 25 Jul 2025 20:30:34 +0530 Subject: [PATCH 01/10] [ECO-5482] Updated LiveObjects interface as per spec - Implemented LiveMapValue as a separate type representing map values - Updated interface method signatures - Updated doc for createMap method with example --- .../java/io/ably/lib/objects/LiveObjects.java | 71 +-- .../lib/objects/type/map/LiveMapValue.java | 443 ++++++++++++++++++ .../io/ably/lib/objects/DefaultLiveObjects.kt | 26 +- 3 files changed, 499 insertions(+), 41 deletions(-) create mode 100644 lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java 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 bd1809f1d..ce6a4f7a4 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjects.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjects.java @@ -3,12 +3,12 @@ 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 io.ably.lib.types.Callback; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; - import java.util.Map; /** @@ -34,46 +34,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. @@ -87,7 +101,7 @@ public interface LiveObjects extends ObjectsStateChange { */ @Blocking @NotNull - LiveCounter createCounter(@NotNull Long initialValue); + LiveCounter createCounter(@NotNull Number initialValue); /** * Asynchronously retrieves the root LiveMap object. @@ -101,43 +115,42 @@ public interface LiveObjects extends ObjectsStateChange { void getRootAsync(@NotNull Callback<@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 Callback<@NotNull LiveMap> callback); + void createMapAsync(@NotNull Callback<@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 Callback<@NotNull LiveMap> callback); + void createMapAsync(@NotNull Map entries, @NotNull Callback<@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 Callback<@NotNull LiveMap> callback); + void createCounterAsync(@NotNull Callback<@NotNull LiveCounter> callback); /** * Asynchronously creates a new LiveCounter with an initial value. @@ -150,5 +163,5 @@ public interface LiveObjects extends ObjectsStateChange { * @param callback the callback to handle the result or error. */ @NonBlocking - void createCounterAsync(@NotNull Long initialValue, @NotNull Callback<@NotNull LiveCounter> callback); + void createCounterAsync(@NotNull Number initialValue, @NotNull Callback<@NotNull LiveCounter> callback); } 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 e94c3540d..24100c6ae 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 @@ -4,6 +4,7 @@ import io.ably.lib.objects.state.ObjectsStateChange import io.ably.lib.objects.state.ObjectsStateEvent 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 io.ably.lib.realtime.ChannelState import io.ably.lib.types.Callback import io.ably.lib.types.ProtocolMessage @@ -49,42 +50,43 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val override fun getRoot(): LiveMap = runBlocking { getRootAsync() } - override fun getRootAsync(callback: Callback) { - GlobalCallbackScope.launchWithCallback(callback) { getRootAsync() } + override fun createMap(): LiveMap { + return createMap(mutableMapOf()) } - override fun createMap(liveMap: LiveMap): LiveMap { + override fun createMap(entries: MutableMap): LiveMap { TODO("Not yet implemented") } - override fun createMap(liveCounter: LiveCounter): LiveMap { - TODO("Not yet implemented") + override fun createCounter(): LiveCounter { + return createCounter(0) } - override fun createMap(map: MutableMap): LiveMap { + override fun createCounter(initialValue: Number): LiveCounter { TODO("Not yet implemented") } - override fun createMapAsync(liveMap: LiveMap, callback: Callback) { - TODO("Not yet implemented") + override fun getRootAsync(callback: Callback) { + GlobalCallbackScope.launchWithCallback(callback) { getRootAsync() } } - override fun createMapAsync(liveCounter: LiveCounter, callback: Callback) { + override fun createMapAsync(callback: Callback) { TODO("Not yet implemented") } - override fun createMapAsync(map: MutableMap, callback: Callback) { + override fun createMapAsync(entries: MutableMap, callback: Callback) { TODO("Not yet implemented") } - override fun createCounterAsync(initialValue: Long, callback: Callback) { + override fun createCounterAsync(callback: Callback) { TODO("Not yet implemented") } - override fun createCounter(initialValue: Long): LiveCounter { + override fun createCounterAsync(initialValue: Number, callback: Callback) { TODO("Not yet implemented") } + override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription = objectsManager.on(event, listener) From 2bff28f7705cad853e8be1b6afbace1037a66b37 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 29 Jul 2025 11:32:52 +0530 Subject: [PATCH 02/10] [ECO-5482] Updated LiveCounter interface as per spec - Updated `increment`,`incrementAsync` method to take `number` as param - Updated `decrement`, `decrementAsync` method to take `number` as param --- .../lib/objects/type/counter/LiveCounter.java | 40 ++++++++++++------- .../type/livecounter/DefaultLiveCounter.kt | 8 ++-- 2 files changed, 29 insertions(+), 19 deletions(-) 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 7084183d1..6c60d9af1 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 Callback 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 Callback 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, Callback)} 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 Callback callback); + void decrementAsync(@NotNull Number amount, @NotNull Callback 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/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 c4637c461..53c40d0e1 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 @@ -40,19 +40,19 @@ internal class DefaultLiveCounter private constructor( private val channelName = liveObjects.channelName private val adapter: LiveObjectsAdapter get() = liveObjects.adapter - override fun increment() { + override fun increment(amount: Number) { TODO("Not yet implemented") } - override fun incrementAsync(callback: Callback) { + override fun decrement(amount: Number) { TODO("Not yet implemented") } - override fun decrement() { + override fun incrementAsync(amount: Number, callback: Callback) { TODO("Not yet implemented") } - override fun decrementAsync(callback: Callback) { + override fun decrementAsync(amount: Number, callback: Callback) { TODO("Not yet implemented") } From 25f37c844593f54b6e6b2799021d3e282f210804 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 29 Jul 2025 17:58:20 +0530 Subject: [PATCH 03/10] [ECO-5482] Updated LiveMap interface to return and perform ops using LiveMapValue - Updated implementation for the same to return LiveMapValue - Fixed related integration tests with the same --- .../io/ably/lib/objects/type/map/LiveMap.java | 12 +- .../kotlin/io/ably/lib/objects/Helpers.kt | 2 + .../ably/lib/objects/type/BaseLiveObject.kt | 2 +- .../objects/type/livemap/DefaultLiveMap.kt | 11 +- .../lib/objects/type/livemap/LiveMapEntry.kt | 34 ++++- .../integration/DefaultLiveCounterTest.kt | 38 +++-- .../objects/integration/DefaultLiveMapTest.kt | 140 +++++++++--------- .../integration/DefaultLiveObjectsTest.kt | 54 +++---- 8 files changed, 160 insertions(+), 133 deletions(-) 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 4bbb49008..7f0d41ed9 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,6 +105,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_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. @@ -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/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..c52afbfdb 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 @@ -1,5 +1,7 @@ package io.ably.lib.objects +import io.ably.lib.objects.type.BaseLiveObject +import io.ably.lib.objects.type.map.LiveMapValue import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.CompletionListener import io.ably.lib.types.ChannelMode 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/livemap/DefaultLiveMap.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt index 1511f392f..f10211170 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,6 +11,7 @@ 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.types.Callback import io.ably.lib.util.Log @@ -45,7 +46,7 @@ internal class DefaultLiveMap private constructor( private val adapter: LiveObjectsAdapter get() = liveObjects.adapter internal val objectsPool: ObjectsPool get() = liveObjects.objectsPool - override fun get(keyName: String): Any? { + override fun get(keyName: String): LiveMapValue? { adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM5b, RTLM5c if (isTombstoned) { return null @@ -56,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 { @@ -78,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) { @@ -92,7 +93,7 @@ internal class DefaultLiveMap private constructor( return data.values.count { !it.isEntryOrRefTombstoned(objectsPool) }.toLong() // RTLM10d } - override fun set(keyName: String, value: Any) { + override fun set(keyName: String, value: LiveMapValue) { TODO("Not yet implemented") } 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..47aa8f9af 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,17 @@ package io.ably.lib.objects.type.livemap +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.ably.lib.objects.* +import io.ably.lib.objects.Binary import io.ably.lib.objects.ObjectData import io.ably.lib.objects.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 +45,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 +68,22 @@ 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 (val value = objValue.value) { + is String -> LiveMapValue.of(value) + is Number -> LiveMapValue.of(value) + is Boolean -> LiveMapValue.of(value) + is Binary -> LiveMapValue.of(value.data) + is JsonObject -> LiveMapValue.of(value) + is JsonArray -> LiveMapValue.of(value) + else -> throw IllegalArgumentException("Unsupported value type: ${value::class.java}") + } +} + +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..2011452c2 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,7 +1,5 @@ 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 @@ -29,64 +27,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 +128,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 +199,7 @@ 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") } @@ -215,11 +213,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 ad265f5af..26e60207c 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,8 +6,6 @@ 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 kotlinx.coroutines.test.runTest import org.junit.Test @@ -32,71 +30,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 +122,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("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("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(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(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 +185,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 +198,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 +210,10 @@ 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().mapNotNull { value -> + if (value.isString) value.asString else null + }.toSet() + assertEquals(setOf("Bob", "bob@example.com"), finalValues, "Final string values should match expected set") } @Test @@ -226,15 +226,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 +254,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 +271,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 +289,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 3bea82c92..a9fd5bdc2 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,16 +1,10 @@ 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.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.map.LiveMap import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @@ -75,70 +69,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) @@ -146,10 +140,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 } From 83c3c6a42cbfdd0d03a9e09dec087a39edf6bc5f Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 29 Jul 2025 20:08:08 +0530 Subject: [PATCH 04/10] [ECO-5482] Implemented helper method to validate write api config --- lib/src/main/java/io/ably/lib/objects/Adapter.java | 10 ++++++---- .../io/ably/lib/objects/LiveObjectsAdapter.java | 10 ++++++++++ .../src/main/kotlin/io/ably/lib/objects/Helpers.kt | 14 ++++++++++++-- 3 files changed, 28 insertions(+), 6 deletions(-) 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..feb91a5d4 100644 --- a/lib/src/main/java/io/ably/lib/objects/Adapter.java +++ b/lib/src/main/java/io/ably/lib/objects/Adapter.java @@ -3,10 +3,7 @@ import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.ChannelState; import io.ably.lib.realtime.CompletionListener; -import io.ably.lib.types.AblyException; -import io.ably.lib.types.ChannelMode; -import io.ably.lib.types.ChannelOptions; -import io.ably.lib.types.ProtocolMessage; +import io.ably.lib.types.*; import io.ably.lib.util.Log; import org.jetbrains.annotations.NotNull; @@ -65,4 +62,9 @@ public ChannelState getChannelState(@NotNull String channelName) { Log.e(TAG, "getChannelState(): channel not found: " + channelName); return null; } + + @Override + public @NotNull ClientOptions getClientOptions() { + return ably.options; + } } 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..70ac6c35e 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java @@ -4,6 +4,7 @@ import io.ably.lib.realtime.CompletionListener; 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.NotNull; import org.jetbrains.annotations.Nullable; @@ -53,5 +54,14 @@ 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(); } 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 c52afbfdb..a6c189aad 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 @@ -1,7 +1,5 @@ package io.ably.lib.objects -import io.ably.lib.objects.type.BaseLiveObject -import io.ably.lib.objects.type.map.LiveMapValue import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.CompletionListener import io.ably.lib.types.ChannelMode @@ -49,6 +47,12 @@ 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)) +} + // Spec: RTO2 internal fun LiveObjectsAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) { val channelModes = getChannelModes(channelName) @@ -65,6 +69,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 From 1355584d41e45cb30fb6355332cc5c955d60760d Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 30 Jul 2025 13:06:36 +0530 Subject: [PATCH 05/10] [ECO-5482] Implemented spec RTO15 for objects realtime publish --- .../main/java/io/ably/lib/objects/Adapter.java | 12 +++++++++++- .../io/ably/lib/objects/LiveObjectsAdapter.java | 10 ++++++++++ .../io/ably/lib/objects/DefaultLiveObjects.kt | 14 ++++++++++++++ .../main/kotlin/io/ably/lib/objects/Helpers.kt | 15 ++++++++++++++- 4 files changed, 49 insertions(+), 2 deletions(-) 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 feb91a5d4..2dbe1f268 100644 --- a/lib/src/main/java/io/ably/lib/objects/Adapter.java +++ b/lib/src/main/java/io/ably/lib/objects/Adapter.java @@ -3,7 +3,12 @@ import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.ChannelState; import io.ably.lib.realtime.CompletionListener; -import io.ably.lib.types.*; +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; @@ -67,4 +72,9 @@ public ChannelState getChannelState(@NotNull String channelName) { public @NotNull ClientOptions getClientOptions() { return ably.options; } + + @Override + public @NotNull ConnectionManager getConnectionManager() { + return ably.connection.connectionManager; + } } 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 70ac6c35e..77bd173ad 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java @@ -2,6 +2,7 @@ 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; @@ -63,5 +64,14 @@ public interface LiveObjectsAdapter { * @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(); } 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 aa4b160cc..6117862ca 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 @@ -106,6 +106,20 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val objectsPool.get(ROOT_OBJECT_ID) as LiveMap } + /** + * 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/Helpers.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Helpers.kt index a6c189aad..17dafe8a9 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) } } @@ -53,6 +59,13 @@ internal fun LiveObjectsAdapter.throwIfInvalidWriteApiConfiguration(channelName: 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) From 814ac2f6744d18db3b0a674e6225e8585561ddf4 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 30 Jul 2025 19:36:59 +0530 Subject: [PATCH 06/10] [ECO-5482] Implemented createMap and createCounter methods for objects - Added basic integration tests for createMap and createCounter --- .../java/io/ably/lib/objects/Adapter.java | 15 +++ .../ably/lib/objects/LiveObjectsAdapter.java | 10 ++ .../io/ably/lib/objects/DefaultLiveObjects.kt | 107 ++++++++++++++---- .../kotlin/io/ably/lib/objects/Helpers.kt | 17 +++ .../kotlin/io/ably/lib/objects/ObjectId.kt | 14 ++- .../main/kotlin/io/ably/lib/objects/Utils.kt | 11 +- .../type/livecounter/DefaultLiveCounter.kt | 9 ++ .../objects/type/livemap/DefaultLiveMap.kt | 18 ++- .../integration/DefaultLiveCounterTest.kt | 20 ++++ .../objects/integration/DefaultLiveMapTest.kt | 35 ++++++ .../io/ably/lib/objects/unit/ObjectIdTest.kt | 20 ++++ .../lib/objects/unit/ObjectMessageSizeTest.kt | 2 +- 12 files changed, 254 insertions(+), 24 deletions(-) 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 2dbe1f268..644db6da9 100644 --- a/lib/src/main/java/io/ably/lib/objects/Adapter.java +++ b/lib/src/main/java/io/ably/lib/objects/Adapter.java @@ -15,6 +15,7 @@ public class Adapter implements LiveObjectsAdapter { private final AblyRealtime ably; private static final String TAG = LiveObjectsAdapter.class.getName(); + private volatile Long serverTimeOffset = null; public Adapter(@NotNull AblyRealtime ably) { this.ably = ably; @@ -77,4 +78,18 @@ public ChannelState getChannelState(@NotNull String channelName) { public @NotNull ConnectionManager getConnectionManager() { return ably.connection.connectionManager; } + + @Override + public long getServerTime() throws AblyException { + if (serverTimeOffset == null) { + synchronized (this) { + if (serverTimeOffset == null) { // Double-checked locking to ensure thread safety + long serverTime = ably.time(); + serverTimeOffset = serverTime - System.currentTimeMillis(); + return serverTime; + } + } + } + return System.currentTimeMillis() + serverTimeOffset; + } } 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 77bd173ad..2c0aa7b11 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java @@ -7,6 +7,7 @@ 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; @@ -73,5 +74,14 @@ public interface LiveObjectsAdapter { * @return the connection manager instance */ @NotNull ConnectionManager getConnectionManager(); + + /** + * Retrieves the current time in milliseconds from the Ably server. + * On first call, queries the server time and caches the offset from local time. + * Subsequent calls return the local time adjusted by this offset. + * Spec: RTO16 + */ + @Blocking + long getServerTime() throws AblyException; } 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 6117862ca..0d9cf0533 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,8 +1,12 @@ 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 @@ -57,40 +61,28 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val override fun getRoot(): LiveMap = runBlocking { getRootAsync() } - override fun createMap(): LiveMap { - return createMap(mutableMapOf()) - } + override fun createMap(): LiveMap = createMap(mutableMapOf()) - override fun createMap(entries: MutableMap): LiveMap { - TODO("Not yet implemented") - } + override fun createMap(entries: MutableMap): LiveMap = runBlocking { createMapAsync(entries) } - override fun createCounter(): LiveCounter { - return createCounter(0) - } + override fun createCounter(): LiveCounter = createCounter(0) - override fun createCounter(initialValue: Number): LiveCounter { - TODO("Not yet implemented") - } + override fun createCounter(initialValue: Number): LiveCounter = runBlocking { createCounterAsync(initialValue) } override fun getRootAsync(callback: Callback) { asyncScope.launchWithCallback(callback) { getRootAsync() } } - override fun createMapAsync(callback: Callback) { - TODO("Not yet implemented") - } + override fun createMapAsync(callback: Callback) = createMapAsync(mutableMapOf(), callback) override fun createMapAsync(entries: MutableMap, callback: Callback) { - TODO("Not yet implemented") + asyncScope.launchWithCallback(callback) { createMapAsync(entries) } } - override fun createCounterAsync(callback: Callback) { - TODO("Not yet implemented") - } + override fun createCounterAsync(callback: Callback) = createCounterAsync(0, callback) override fun createCounterAsync(initialValue: Number, callback: Callback) { - TODO("Not yet implemented") + asyncScope.launchWithCallback(callback) { createCounterAsync(initialValue) } } override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription = @@ -106,6 +98,81 @@ 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) + + // Create initial value operation + val initialMapValue = DefaultLiveMap.initialValue(entries) + + // Create initial value JSON string + val initialValueJSONString = gson.toJson(initialMapValue) + + // 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, + ) + ) + + // Publish the message + publish(arrayOf(msg)) + + // 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) + + // Validate input parameter + if (initialValue.toDouble().isNaN() || initialValue.toDouble().isInfinite()) { + throw objectError("Counter value should be a valid number") + } + + val initialCounterValue = DefaultLiveCounter.initialValue(initialValue) + // Create initial value operation + val initialValueJSONString = gson.toJson(initialCounterValue) + + // 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 + ) + ) + + // Publish the message + publish(arrayOf(msg)) + + // 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 + } + } + + private suspend fun getObjectIdStringWithNonce(objectType: ObjectType, initialValue: String): Pair { + val nonce = generateNonce() + val msTimestamp = withContext(Dispatchers.IO) { + adapter.getServerTime() + } + return Pair(ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp).toString(), nonce) + } + /** * Spec: RTO15 */ 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 17dafe8a9..d3b6e94ed 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 @@ -1,5 +1,7 @@ package io.ably.lib.objects +import io.ably.lib.objects.type.BaseLiveObject +import io.ably.lib.objects.type.map.LiveMapValue import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.CompletionListener import io.ably.lib.types.ChannelMode @@ -103,3 +105,18 @@ 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 +) + +internal fun fromLiveMapValue(value: LiveMapValue) : ObjectData { + return when { + value.isLiveMap || value.isLiveCounter -> ObjectData(objectId = (value.value as BaseLiveObject).objectId) + else -> ObjectData(value = ObjectValue(value.value)) + } +} 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..0f19ced7c 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, @@ -15,10 +18,19 @@ internal class ObjectId private constructor( } companion object { + + internal fun fromInitialValue(objectType: ObjectType, initialValue: String, nonce: String, msTimeStamp: Long): ObjectId { + val valueForHash = "$initialValue:$nonce".toByteArray(StandardCharsets.UTF_8) + 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/Utils.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt index f3eebf782..79693eeff 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 @@ -5,6 +5,7 @@ import io.ably.lib.types.Callback 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( @@ -47,7 +48,7 @@ internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyEx * 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. @@ -78,3 +79,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" + return (1..16).map { chars.random() }.joinToString("") +} 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 53c40d0e1..d48ab8b86 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 @@ -105,5 +105,14 @@ internal class DefaultLiveCounter private constructor( internal fun zeroValue(objectId: String, liveObjects: DefaultLiveObjects): DefaultLiveCounter { return DefaultLiveCounter(objectId, liveObjects) } + + /** + * Creates initial value operation for counter creation. + */ + 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 f10211170..c8f7daa64 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 @@ -18,7 +18,6 @@ import io.ably.lib.util.Log import java.util.concurrent.ConcurrentHashMap import java.util.AbstractMap - /** * Implementation of LiveObject for LiveMap. * @@ -153,5 +152,22 @@ internal class DefaultLiveMap private constructor( internal fun zeroValue(objectId: String, objects: DefaultLiveObjects): DefaultLiveMap { return DefaultLiveMap(objectId, objects) } + + /** + * Creates an ObjectMap from map entries. + */ + internal fun initialValue(entries: MutableMap): MapCreatePayload { + return MapCreatePayload( + map = ObjectMap( + semantics = MapSemantics.LWW, + entries = entries.mapValues { (_, value) -> + ObjectMapEntry( + tombstone = false, + data = fromLiveMapValue(value) + ) + } + ) + ) + } } } 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 2011452c2..93507be1d 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 @@ -204,6 +204,26 @@ class DefaultLiveCounterTest: IntegrationTest() { 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 testCounterObjectId = objects.createCounter( 10.0) + restObjects.setMapRef(channelName, "root", "testCounter", testCounterObjectId.ObjectId) + + // 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") + } + @Test fun testLiveCounterChangesUsingSubscription() = runTest { val channelName = generateChannelName() 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 26e60207c..81f0e61ff 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 @@ -3,10 +3,12 @@ package io.ably.lib.objects.integration import io.ably.lib.objects.* import io.ably.lib.objects.ObjectData import io.ably.lib.objects.ObjectValue +import io.ably.lib.objects.integration.helpers.ObjectId 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.map.LiveMapUpdate +import io.ably.lib.objects.type.map.LiveMapValue import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @@ -216,6 +218,39 @@ class DefaultLiveMapTest: IntegrationTest() { assertEquals(setOf("Bob", "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 testMapObjectId = objects.createMap( + mapOf( + "name" to LiveMapValue.of("Alice"), + "age" to LiveMapValue.of(30), + "isActive" to LiveMapValue.of(true), + ) + ) + restObjects.setMapRef(channelName, "root", "testMap", testMapObjectId.ObjectId) + + // 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") + } + @Test fun testLiveMapChangesUsingSubscription() = runTest { val channelName = generateChannelName() 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 5cf383c7a..d98efa1cb 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) } } From b02cfd7fe3b251363faf83157aa3478eb469d94a Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 31 Jul 2025 16:04:32 +0530 Subject: [PATCH 07/10] [ECO-5482] Implemented realtime write methods for LiveMap and LiveCounter - Added integration test performing counter ops using realtime write API - Added integration test performing map ops using realtime write API --- .../io/ably/lib/objects/type/map/LiveMap.java | 2 +- .../io/ably/lib/objects/DefaultLiveObjects.kt | 2 +- .../kotlin/io/ably/lib/objects/Helpers.kt | 9 -- .../main/kotlin/io/ably/lib/objects/Utils.kt | 14 +++ .../type/livecounter/DefaultLiveCounter.kt | 36 ++++++-- .../objects/type/livemap/DefaultLiveMap.kt | 70 ++++++++++++-- .../integration/DefaultLiveCounterTest.kt | 76 +++++++++++++++- .../objects/integration/DefaultLiveMapTest.kt | 91 ++++++++++++++++++- 8 files changed, 267 insertions(+), 33 deletions(-) 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 7f0d41ed9..dc3ed284c 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 @@ -112,7 +112,7 @@ public interface LiveMap extends LiveMapChange { * @param callback the callback to handle the result or any errors. */ @NonBlocking - void setAsync(@NotNull String keyName, @NotNull Object value, @NotNull Callback callback); + void setAsync(@NotNull String keyName, @NotNull LiveMapValue value, @NotNull Callback callback); /** * Asynchronously removes the specified key and its associated value from the map. 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 0d9cf0533..bf68bc2ce 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 @@ -53,7 +53,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() 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 d3b6e94ed..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 @@ -1,7 +1,5 @@ package io.ably.lib.objects -import io.ably.lib.objects.type.BaseLiveObject -import io.ably.lib.objects.type.map.LiveMapValue import io.ably.lib.realtime.ChannelState import io.ably.lib.realtime.CompletionListener import io.ably.lib.types.ChannelMode @@ -113,10 +111,3 @@ internal data class CounterCreatePayload( internal data class MapCreatePayload( val map: ObjectMap ) - -internal fun fromLiveMapValue(value: LiveMapValue) : ObjectData { - return when { - value.isLiveMap || value.isLiveCounter -> ObjectData(objectId = (value.value as BaseLiveObject).objectId) - else -> ObjectData(value = ObjectValue(value.value)) - } -} 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 79693eeff..2106284cb 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 @@ -75,6 +75,20 @@ internal class ObjectsAsyncScope(channelName: String) { } } + internal fun launchWithVoidCallback(callback: Callback, block: suspend () -> Unit) { + scope.launch { + try { + block() + try { callback.onSuccess(null) } catch (t: Throwable) { + Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) + } // catch and don't rethrow error from callback + } catch (throwable: Throwable) { + val exception = throwable as? AblyException + callback.onError(exception?.errorInfo) + } + } + } + internal fun cancel(cause: CancellationException) { scope.coroutineContext.cancelChildren(cause) } 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 d48ab8b86..1b5b6d620 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 @@ -13,6 +13,7 @@ import io.ably.lib.objects.type.noOp import io.ably.lib.types.Callback import java.util.concurrent.atomic.AtomicReference import io.ably.lib.util.Log +import kotlinx.coroutines.runBlocking /** * Implementation of LiveObject for LiveCounter. @@ -39,21 +40,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(amount: Number) { - TODO("Not yet implemented") - } + override fun increment(amount: Number) = runBlocking { incrementAsync(amount.toDouble()) } - override fun decrement(amount: Number) { - TODO("Not yet implemented") - } + override fun decrement(amount: Number) = runBlocking { incrementAsync(-amount.toDouble()) } override fun incrementAsync(amount: Number, callback: Callback) { - TODO("Not yet implemented") + asyncScope.launchWithVoidCallback(callback) { incrementAsync(amount.toDouble()) } } override fun decrementAsync(amount: Number, callback: Callback) { - TODO("Not yet implemented") + asyncScope.launchWithVoidCallback(callback) { incrementAsync(-amount.toDouble()) } } override fun value(): Double { @@ -72,6 +70,28 @@ internal class DefaultLiveCounter private constructor( override fun validate(state: ObjectState) = liveCounterManager.validate(state) + private suspend fun incrementAsync(amount: Double) { + // Validate write API configuration + adapter.throwIfInvalidWriteApiConfiguration(channelName) + + // Validate input parameter + if (amount.isNaN() || amount.isInfinite()) { + throw objectError("Counter value increment should be a valid number") + } + + // Create ObjectMessage with the COUNTER_INC operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.CounterInc, + objectId = objectId, + counterOp = ObjectCounterOp(amount = amount) + ) + ) + + // Publish the message + liveObjects.publish(arrayOf(msg)) + } + override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveCounterUpdate { return liveCounterManager.applyState(objectState, message.serialTimestamp) } 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 c8f7daa64..bda67a172 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 @@ -15,6 +15,7 @@ import io.ably.lib.objects.type.map.LiveMapValue import io.ably.lib.objects.type.noOp import io.ably.lib.types.Callback import io.ably.lib.util.Log +import kotlinx.coroutines.runBlocking import java.util.concurrent.ConcurrentHashMap import java.util.AbstractMap @@ -44,6 +45,7 @@ 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): LiveMapValue? { adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM5b, RTLM5c @@ -92,20 +94,16 @@ internal class DefaultLiveMap private constructor( return data.values.count { !it.isEntryOrRefTombstoned(objectsPool) }.toLong() // RTLM10d } - override fun set(keyName: String, value: LiveMapValue) { - 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: Callback) { - TODO("Not yet implemented") + override fun setAsync(keyName: String, value: LiveMapValue, callback: Callback) { + asyncScope.launchWithVoidCallback(callback) { setAsync(keyName, value) } } override fun removeAsync(keyName: String, callback: Callback) { - TODO("Not yet implemented") + asyncScope.launchWithVoidCallback(callback) { removeAsync(keyName) } } override fun validate(state: ObjectState) = liveMapManager.validate(state) @@ -119,6 +117,53 @@ internal class DefaultLiveMap private constructor( override fun unsubscribeAll() = liveMapManager.unsubscribeAll() + private suspend fun setAsync(keyName: String, value: LiveMapValue) { + // Validate write API configuration + adapter.throwIfInvalidWriteApiConfiguration(channelName) + + // Validate input parameters + if (keyName.isEmpty()) { + throw objectError("Map key should not be empty") + } + + // Create ObjectMessage with the MAP_SET operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.MapSet, + objectId = objectId, + mapOp = ObjectMapOp( + key = keyName, + data = fromLiveMapValue(value) + ) + ) + ) + + // Publish the message + liveObjects.publish(arrayOf(msg)) + } + + private suspend fun removeAsync(keyName: String) { + // Validate write API configuration + adapter.throwIfInvalidWriteApiConfiguration(channelName) + + // Validate input parameter + if (keyName.isEmpty()) { + throw objectError("Map key should not be empty") + } + + // Create ObjectMessage with the MAP_REMOVE operation + val msg = ObjectMessage( + operation = ObjectOperation( + action = ObjectOperationAction.MapRemove, + objectId = objectId, + mapOp = ObjectMapOp(key = keyName) + ) + ) + + // Publish the message + liveObjects.publish(arrayOf(msg)) + } + override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveMapUpdate { return liveMapManager.applyState(objectState, message.serialTimestamp) } @@ -169,5 +214,12 @@ internal class DefaultLiveMap private constructor( ) ) } + + private fun fromLiveMapValue(value: LiveMapValue) : ObjectData { + return when { + value.isLiveMap || value.isLiveCounter -> ObjectData(objectId = (value.value as BaseLiveObject).objectId) + else -> ObjectData(value = ObjectValue(value.value)) + } + } } } 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 93507be1d..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 @@ -5,6 +5,7 @@ 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 @@ -212,8 +213,8 @@ class DefaultLiveCounterTest: IntegrationTest() { val rootMap = channel.objects.root // Step 1: Create a new counter with initial value of 10 - val testCounterObjectId = objects.createCounter( 10.0) - restObjects.setMapRef(channelName, "root", "testCounter", testCounterObjectId.ObjectId) + 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 } @@ -222,6 +223,77 @@ class DefaultLiveCounterTest: IntegrationTest() { 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") } @Test 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 81f0e61ff..dafc7acb3 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 @@ -3,7 +3,6 @@ package io.ably.lib.objects.integration import io.ably.lib.objects.* import io.ably.lib.objects.ObjectData import io.ably.lib.objects.ObjectValue -import io.ably.lib.objects.integration.helpers.ObjectId 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 @@ -230,14 +229,14 @@ class DefaultLiveMapTest: IntegrationTest() { val rootMap = channel.objects.root // Step 1: Create a new map with initial data - val testMapObjectId = objects.createMap( + val testMapObject = objects.createMap( mapOf( "name" to LiveMapValue.of("Alice"), "age" to LiveMapValue.of(30), "isActive" to LiveMapValue.of(true), ) ) - restObjects.setMapRef(channelName, "root", "testMap", testMapObjectId.ObjectId) + rootMap.set("testMap", LiveMapValue.of(testMapObject)) // wait for updated testMap to be available in the root map assertWaiter { rootMap.get("testMap") != null } @@ -249,6 +248,92 @@ class DefaultLiveMapTest: IntegrationTest() { 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().mapNotNull { value -> + if (value.isString) value.asString else null + }.toSet() + assertEquals(setOf("Bob", "bob@example.com"), finalValues, "Final string values should match expected set") } @Test From 5362b35ca73cff719808cfff04be746f2b5785ad Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 31 Jul 2025 17:24:14 +0530 Subject: [PATCH 08/10] [ECO-5482] Added unit tests for nonce, stringByteSize and ObjectsAsyncScope --- .../io/ably/lib/objects/DefaultLiveObjects.kt | 4 + .../main/kotlin/io/ably/lib/objects/Utils.kt | 2 +- .../io/ably/lib/objects/unit/UtilsTest.kt | 296 ++++++++++++++++++ 3 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt 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 bf68bc2ce..1866a78b4 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 @@ -101,6 +101,10 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val private suspend fun createMapAsync(entries: MutableMap): LiveMap { adapter.throwIfInvalidWriteApiConfiguration(channelName) + if (entries.keys.any { it.isEmpty() }) { + throw objectError("Map keys should not be empty") + } + // Create initial value operation val initialMapValue = DefaultLiveMap.initialValue(entries) 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 2106284cb..1120c440d 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 @@ -98,6 +98,6 @@ internal class ObjectsAsyncScope(channelName: String) { * Generates a random nonce string for object creation. */ internal fun generateNonce(): String { - val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // avoid calculation using range return (1..16).map { chars.random() }.joinToString("") } 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 new file mode 100644 index 000000000..dc28a9b31 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt @@ -0,0 +1,296 @@ +package io.ably.lib.objects.unit + +import io.ably.lib.objects.* +import io.ably.lib.objects.assertWaiter +import io.ably.lib.types.AblyException +import io.ably.lib.types.Callback +import io.ably.lib.types.ErrorInfo +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import org.junit.Test +import org.junit.Assert.* +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 + assertEquals(5, "Hello".byteSize) + assertEquals(0, "".byteSize) + assertEquals(1, "A".byteSize) + + // Test non-ASCII strings + assertEquals(3, "你".byteSize) // Chinese character + assertEquals(4, "😊".byteSize) // Emoji + assertEquals(6, "你好".byteSize) // Two Chinese characters + } + + @Test + fun testErrorCreationFunctions() { + // Test clientError + val clientEx = clientError("Bad request") + assertEquals("Bad request", clientEx.errorInfo.message) + assertEquals(ErrorCode.BadRequest.code, clientEx.errorInfo.code) + assertEquals(HttpStatusCode.BadRequest.code, clientEx.errorInfo.statusCode) + + // Test serverError + val serverEx = serverError("Internal error") + assertEquals("Internal error", serverEx.errorInfo.message) + assertEquals(ErrorCode.InternalError.code, serverEx.errorInfo.code) + assertEquals(HttpStatusCode.InternalServerError.code, serverEx.errorInfo.statusCode) + + // Test objectError + val objectEx = objectError("Invalid object") + assertEquals("Invalid object", objectEx.errorInfo.message) + assertEquals(ErrorCode.InvalidObject.code, objectEx.errorInfo.code) + assertEquals(HttpStatusCode.InternalServerError.code, objectEx.errorInfo.statusCode) + + // Test objectError with cause + val cause = RuntimeException("Original error") + val objectExWithCause = objectError("Invalid object", cause) + assertEquals("Invalid object", objectExWithCause.errorInfo.message) + assertEquals(cause, objectExWithCause.cause) + } + + @Test + fun testAblyExceptionCreation() { + // Test with error message and codes + val ex = ablyException("Test error", ErrorCode.BadRequest, HttpStatusCode.BadRequest) + assertEquals("Test error", ex.errorInfo.message) + assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code) + assertEquals(HttpStatusCode.BadRequest.code, ex.errorInfo.statusCode) + + // Test with ErrorInfo + val errorInfo = ErrorInfo("Custom error", 400, 40000) + val ex2 = ablyException(errorInfo) + assertEquals("Custom error", ex2.errorInfo.message) + assertEquals(400, ex2.errorInfo.statusCode) + assertEquals(40000, ex2.errorInfo.code) + + // Test with cause + val cause = RuntimeException("Cause") + val ex3 = ablyException(errorInfo, cause) + assertEquals(cause, ex3.cause) + } + + @Test + fun testObjectsAsyncScopeLaunchWithCallback() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var callbackExecuted = false + var resultReceived: String? = null + + val callback = object : Callback { + override fun onSuccess(result: String) { + callbackExecuted = true + resultReceived = result + } + + override fun onError(errorInfo: ErrorInfo?) { + fail("Should not call onError for successful execution") + } + } + + asyncScope.launchWithCallback(callback) { + delay(10) // Simulate async work + "test result" + } + + // Wait for callback to be executed + assertWaiter { callbackExecuted } + + assertTrue("Callback should be executed", callbackExecuted) + assertEquals("test result", resultReceived) + } + + @Test + fun testObjectsAsyncScopeLaunchWithCallbackError() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived: ErrorInfo? = null + + val callback = object : Callback { + override fun onSuccess(result: String) { + fail("Should not call onSuccess for error case") + } + + override fun onError(errorInfo: ErrorInfo?) { + errorReceived = errorInfo + } + } + + asyncScope.launchWithCallback(callback) { + delay(10) + throw AblyException.fromErrorInfo(ErrorInfo("Test error", 400, 40000)) + } + + // Wait for error to be received + assertWaiter { errorReceived != null } + + assertNotNull("Error should be received", errorReceived) + assertEquals("Test error", errorReceived?.message) + assertEquals(400, errorReceived?.statusCode) + } + + @Test + fun testObjectsAsyncScopeLaunchWithVoidCallback() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var callbackExecuted = false + + val callback = object : Callback { + override fun onSuccess(result: Void?) { + callbackExecuted = true + } + + override fun onError(errorInfo: ErrorInfo?) { + fail("Should not call onError for successful execution") + } + } + + asyncScope.launchWithVoidCallback(callback) { + delay(10) // Simulate async work + } + + // Wait for callback to be executed + assertWaiter { callbackExecuted } + + assertTrue("Callback should be executed", callbackExecuted) + } + + @Test + fun testObjectsAsyncScopeLaunchWithVoidCallbackError() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived: ErrorInfo? = null + + val callback = object : Callback { + override fun onSuccess(result: Void?) { + fail("Should not call onSuccess for error case") + } + + override fun onError(errorInfo: ErrorInfo?) { + errorReceived = errorInfo + } + } + + asyncScope.launchWithVoidCallback(callback) { + delay(10) + throw AblyException.fromErrorInfo(ErrorInfo("Test error", 500, 50000)) + } + + // Wait for error to be received + assertWaiter { errorReceived != null } + + assertNotNull("Error should be received", errorReceived) + assertEquals("Test error", errorReceived?.message) + assertEquals(500, errorReceived?.statusCode) + } + + @Test + fun testObjectsAsyncScopeCallbackExceptionHandling() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var callback1Called = false + var callback2Called = false + + val callback1 = object : Callback { + override fun onSuccess(result: String) { + callback1Called = true + throw RuntimeException("Callback exception") + } + + override fun onError(errorInfo: ErrorInfo?) { + fail("Should not call onError when onSuccess throws") + } + } + + asyncScope.launchWithCallback(callback1) { "test result" } + // Wait for callback to be called + assertWaiter { callback1Called } + + val callback2 = object : Callback { + override fun onSuccess(result: String) { + callback2Called = true + } + + override fun onError(errorInfo: ErrorInfo?) { + fail("Should not call onError when onSuccess throws") + } + } + + asyncScope.launchWithCallback(callback2) { "test result" } + // Callback 2 should be called even if callback 1 throws an exception + assertWaiter { callback2Called } + } + + @Test + fun testObjectsAsyncScopeCancel() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived = false + + val callback = object : Callback { + override fun onSuccess(result: String) { + fail("Should not call onSuccess") + } + + override fun onError(errorInfo: ErrorInfo?) { + errorReceived = true + } + } + + asyncScope.launchWithCallback(callback) { + delay(100) // Long delay + "test result" + } + + // Cancel immediately + asyncScope.cancel(CancellationException("Test cancellation")) + + // Wait a bit to ensure cancellation takes effect + assertWaiter { errorReceived } + } + + @Test + fun testObjectsAsyncScopeNonAblyException() = runTest { + val asyncScope = ObjectsAsyncScope("test-channel") + var errorReceived = false + var error: ErrorInfo? = null + + val callback = object : Callback { + override fun onSuccess(result: String) { + fail("Should not call onSuccess for error case") + } + + override fun onError(errorInfo: ErrorInfo?) { + errorReceived = true + error = errorInfo + } + } + + asyncScope.launchWithCallback(callback) { + delay(10) + throw RuntimeException("Non-Ably exception") + } + + // Wait for error to be received + assertWaiter { errorReceived } + + // Non-Ably exceptions should result in null errorInfo + assertNull("Non-Ably exceptions should result in null errorInfo", error) + } +} From 825480b1e223d741f25a4e61d3c7371d36d572ed Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 31 Jul 2025 17:54:18 +0530 Subject: [PATCH 09/10] [ECO-5482] Simplified Adapter for getting server time - Added separate object class for getting current server time - Added thread safety to return current server time --- .../java/io/ably/lib/objects/Adapter.java | 14 ++-------- .../ably/lib/objects/LiveObjectsAdapter.java | 4 +-- .../io/ably/lib/objects/DefaultLiveObjects.kt | 4 +-- .../kotlin/io/ably/lib/objects/ServerTime.kt | 28 +++++++++++++++++++ 4 files changed, 32 insertions(+), 18 deletions(-) create mode 100644 live-objects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt 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 644db6da9..b8fa905cd 100644 --- a/lib/src/main/java/io/ably/lib/objects/Adapter.java +++ b/lib/src/main/java/io/ably/lib/objects/Adapter.java @@ -15,7 +15,6 @@ public class Adapter implements LiveObjectsAdapter { private final AblyRealtime ably; private static final String TAG = LiveObjectsAdapter.class.getName(); - private volatile Long serverTimeOffset = null; public Adapter(@NotNull AblyRealtime ably) { this.ably = ably; @@ -80,16 +79,7 @@ public ChannelState getChannelState(@NotNull String channelName) { } @Override - public long getServerTime() throws AblyException { - if (serverTimeOffset == null) { - synchronized (this) { - if (serverTimeOffset == null) { // Double-checked locking to ensure thread safety - long serverTime = ably.time(); - serverTimeOffset = serverTime - System.currentTimeMillis(); - return serverTime; - } - } - } - return System.currentTimeMillis() + serverTimeOffset; + public long getTime() throws AblyException { + return ably.time(); } } 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 2c0aa7b11..fd074fd66 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjectsAdapter.java @@ -77,11 +77,9 @@ public interface LiveObjectsAdapter { /** * Retrieves the current time in milliseconds from the Ably server. - * On first call, queries the server time and caches the offset from local time. - * Subsequent calls return the local time adjusted by this offset. * Spec: RTO16 */ @Blocking - long getServerTime() throws AblyException; + long getTime() throws AblyException; } 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 1866a78b4..a043276c4 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 @@ -171,9 +171,7 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val private suspend fun getObjectIdStringWithNonce(objectType: ObjectType, initialValue: String): Pair { val nonce = generateNonce() - val msTimestamp = withContext(Dispatchers.IO) { - adapter.getServerTime() - } + val msTimestamp = ServerTime.getCurrentTime(adapter) // RTO16 - Get server time for nonce generation return Pair(ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp).toString(), nonce) } 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..69f22b3fc --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt @@ -0,0 +1,28 @@ +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 + +internal object ServerTime { + @Volatile + private var serverTimeOffset: Long? = null + private val mutex = Mutex() + + @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!! + } +} From c4dba39cefaa1c864af0a92b043fc9a0ff18510c Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 4 Aug 2025 15:50:00 +0530 Subject: [PATCH 10/10] [ECO-5482] Annotated objects realtime API with relevant spec ids --- .../java/io/ably/lib/objects/LiveObjects.java | 1 - .../io/ably/lib/objects/DefaultLiveObjects.kt | 32 +++++++++++-------- .../kotlin/io/ably/lib/objects/ErrorCodes.kt | 2 +- .../kotlin/io/ably/lib/objects/ObjectId.kt | 5 +++ .../kotlin/io/ably/lib/objects/ServerTime.kt | 7 ++++ .../main/kotlin/io/ably/lib/objects/Utils.kt | 5 +++ .../type/livecounter/DefaultLiveCounter.kt | 11 ++++--- .../objects/type/livemap/DefaultLiveMap.kt | 20 +++++++----- .../objects/integration/DefaultLiveMapTest.kt | 12 +++---- .../integration/helpers/PayloadBuilder.kt | 11 ++----- 10 files changed, 61 insertions(+), 45 deletions(-) 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 92e78c415..33db8b63c 100644 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjects.java +++ b/lib/src/main/java/io/ably/lib/objects/LiveObjects.java @@ -4,7 +4,6 @@ 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 io.ably.lib.types.Callback; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NotNull; 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 88436ba33..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 @@ -98,19 +98,19 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val } private suspend fun createMapAsync(entries: MutableMap): LiveMap { - adapter.throwIfInvalidWriteApiConfiguration(channelName) + adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO11c, RTO11d, RTO11e - if (entries.keys.any { it.isEmpty() }) { - throw objectError("Map keys should not be empty") + if (entries.keys.any { it.isEmpty() }) { // RTO11f2 + throw invalidInputError("Map keys should not be empty") } - // Create initial value operation + // RTO11f4 - Create initial value operation val initialMapValue = DefaultLiveMap.initialValue(entries) - // Create initial value JSON string + // RTO11f5 - Create initial value JSON string val initialValueJSONString = gson.toJson(initialMapValue) - // Create object ID from initial value + // RTO11f8 - Create object ID from initial value val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Map, initialValueJSONString) // Create ObjectMessage with the operation @@ -124,28 +124,29 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val ) ) - // Publish the message + // RTO11g - Publish the message publish(arrayOf(msg)) - // Check if object already exists in pool, otherwise create a zero-value object using the sequential scope + // 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) + adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO12c, RTO12d, RTO12e // Validate input parameter if (initialValue.toDouble().isNaN() || initialValue.toDouble().isInfinite()) { - throw objectError("Counter value should be a valid number") + throw invalidInputError("Counter value should be a valid number") } + // RTO12f2 val initialCounterValue = DefaultLiveCounter.initialValue(initialValue) - // Create initial value operation + // RTO12f3 - Create initial value operation val initialValueJSONString = gson.toJson(initialCounterValue) - // Create object ID from initial value + // RTO12f6- Create object ID from initial value val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Counter, initialValueJSONString) // Create ObjectMessage with the operation @@ -159,15 +160,18 @@ internal class DefaultLiveObjects(internal val channelName: String, internal val ) ) - // Publish the message + // RTO12g - Publish the message publish(arrayOf(msg)) - // Check if object already exists in pool, otherwise create a zero-value object using the sequential scope + // 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 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/ObjectId.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt index 0f19ced7c..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 @@ -12,6 +12,7 @@ internal class ObjectId private constructor( ) { /** * Converts ObjectId to string representation. + * Spec: RTO6b1 */ override fun toString(): String { return "${type.value}:$hash@$timestampMs" @@ -19,8 +20,12 @@ internal class ObjectId private constructor( 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) 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 index 69f22b3fc..d8d4a8eb5 100644 --- a/live-objects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt @@ -7,11 +7,18 @@ 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) { 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 45c2930a6..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 @@ -41,6 +41,11 @@ 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. 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 78309c8fa..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 @@ -70,15 +70,15 @@ internal class DefaultLiveCounter private constructor( override fun validate(state: ObjectState) = liveCounterManager.validate(state) private suspend fun incrementAsync(amount: Double) { - // Validate write API configuration + // RTLC12b, RTLC12c, RTLC12d - Validate write API configuration adapter.throwIfInvalidWriteApiConfiguration(channelName) - // Validate input parameter + // RTLC12e1 - Validate input parameter if (amount.isNaN() || amount.isInfinite()) { - throw objectError("Counter value increment should be a valid number") + throw invalidInputError("Counter value increment should be a valid number") } - // Create ObjectMessage with the COUNTER_INC operation + // RTLC12e2, RTLC12e3, RTLC12e4 - Create ObjectMessage with the COUNTER_INC operation val msg = ObjectMessage( operation = ObjectOperation( action = ObjectOperationAction.CounterInc, @@ -87,7 +87,7 @@ internal class DefaultLiveCounter private constructor( ) ) - // Publish the message + // RTLC12f - Publish the message liveObjects.publish(arrayOf(msg)) } @@ -127,6 +127,7 @@ internal class DefaultLiveCounter private constructor( /** * Creates initial value operation for counter creation. + * Spec: RTO12f2 */ internal fun initialValue(count: Number): CounterCreatePayload { return CounterCreatePayload( 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 f2ba89882..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 @@ -117,15 +117,15 @@ internal class DefaultLiveMap private constructor( override fun unsubscribeAll() = liveMapManager.unsubscribeAll() private suspend fun setAsync(keyName: String, value: LiveMapValue) { - // Validate write API configuration + // RTLM20b, RTLM20c, RTLM20d - Validate write API configuration adapter.throwIfInvalidWriteApiConfiguration(channelName) // Validate input parameters if (keyName.isEmpty()) { - throw objectError("Map key should not be empty") + throw invalidInputError("Map key should not be empty") } - // Create ObjectMessage with the MAP_SET operation + // RTLM20e - Create ObjectMessage with the MAP_SET operation val msg = ObjectMessage( operation = ObjectOperation( action = ObjectOperationAction.MapSet, @@ -137,20 +137,20 @@ internal class DefaultLiveMap private constructor( ) ) - // Publish the message + // RTLM20f - Publish the message liveObjects.publish(arrayOf(msg)) } private suspend fun removeAsync(keyName: String) { - // Validate write API configuration + // RTLM21b, RTLM21cm RTLM21d - Validate write API configuration adapter.throwIfInvalidWriteApiConfiguration(channelName) // Validate input parameter if (keyName.isEmpty()) { - throw objectError("Map key should not be empty") + throw invalidInputError("Map key should not be empty") } - // Create ObjectMessage with the MAP_REMOVE operation + // RTLM21e - Create ObjectMessage with the MAP_REMOVE operation val msg = ObjectMessage( operation = ObjectOperation( action = ObjectOperationAction.MapRemove, @@ -159,7 +159,7 @@ internal class DefaultLiveMap private constructor( ) ) - // Publish the message + // RTLM21f - Publish the message liveObjects.publish(arrayOf(msg)) } @@ -199,6 +199,7 @@ internal class DefaultLiveMap private constructor( /** * Creates an ObjectMap from map entries. + * Spec: RTO11f4 */ internal fun initialValue(entries: MutableMap): MapCreatePayload { return MapCreatePayload( @@ -214,6 +215,9 @@ internal class DefaultLiveMap private constructor( ) } + /** + * Spec: RTLM20e5 + */ private fun fromLiveMapValue(value: LiveMapValue): ObjectData { return when { value.isLiveMap || value.isLiveCounter -> { 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 3b81a9848..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 @@ -211,10 +211,8 @@ class DefaultLiveMapTest: IntegrationTest() { val finalKeys = testMap.keys().toSet() assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") - val finalValues = testMap.values().mapNotNull { value -> - if (value.isString) value.asString else null - }.toSet() - assertEquals(setOf("Bob", "bob@example.com"), finalValues, "Final string 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") } /** @@ -330,10 +328,8 @@ class DefaultLiveMapTest: IntegrationTest() { val finalKeys = testMap.keys().toSet() assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") - val finalValues = testMap.values().mapNotNull { value -> - if (value.isString) value.asString else null - }.toSet() - assertEquals(setOf("Bob", "bob@example.com"), finalValues, "Final string 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") } @Test 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