getEntries();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/MapRemove.java b/lib/src/main/java/io/ably/lib/liveobjects/message/MapRemove.java
new file mode 100644
index 000000000..fccb67464
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/message/MapRemove.java
@@ -0,0 +1,21 @@
+package io.ably.lib.liveobjects.message;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Payload of a {@link ObjectOperationAction#MAP_REMOVE} operation, describing a key
+ * being removed from a {@code LiveMap} object.
+ *
+ * Spec: MRM*
+ */
+public interface MapRemove {
+
+ /**
+ * Returns the key being removed.
+ *
+ *
Spec: MRM2a
+ *
+ * @return the map key
+ */
+ @NotNull String getKey();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/MapSet.java b/lib/src/main/java/io/ably/lib/liveobjects/message/MapSet.java
new file mode 100644
index 000000000..6b63e88cf
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/message/MapSet.java
@@ -0,0 +1,30 @@
+package io.ably.lib.liveobjects.message;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Payload of a {@link ObjectOperationAction#MAP_SET} operation, describing a key being
+ * set on a {@code LiveMap} object.
+ *
+ *
Spec: MST*
+ */
+public interface MapSet {
+
+ /**
+ * Returns the key being set.
+ *
+ *
Spec: MST2a
+ *
+ * @return the map key
+ */
+ @NotNull String getKey();
+
+ /**
+ * Returns the value the key is being set to.
+ *
+ *
Spec: MST2b
+ *
+ * @return the value being set
+ */
+ @NotNull ObjectData getValue();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectData.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectData.java
new file mode 100644
index 000000000..b98e53920
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectData.java
@@ -0,0 +1,71 @@
+package io.ably.lib.liveobjects.message;
+
+import com.google.gson.JsonElement;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Represents a value in an object on a channel. A value is either a reference to another
+ * object ({@link #getObjectId()}) or exactly one of the primitive payloads
+ * ({@link #getString()}, {@link #getNumber()}, {@link #getBoolean()},
+ * {@link #getBytes()}, {@link #getJson()}).
+ *
+ *
Spec: OD1
+ */
+public interface ObjectData {
+
+ /**
+ * Returns a reference to another object, used to support composable object
+ * structures.
+ *
+ *
Spec: OD2a
+ *
+ * @return the referenced object id, or {@code null} if this value is a primitive
+ */
+ @Nullable String getObjectId();
+
+ /**
+ * Returns the string value.
+ *
+ *
Spec: OD2f
+ *
+ * @return the string value, or {@code null} if not applicable
+ */
+ @Nullable String getString();
+
+ /**
+ * Returns the numeric value.
+ *
+ *
Spec: OD2e
+ *
+ * @return the numeric value, or {@code null} if not applicable
+ */
+ @Nullable Double getNumber();
+
+ /**
+ * Returns the boolean value.
+ *
+ *
Spec: OD2c
+ *
+ * @return the boolean value, or {@code null} if not applicable
+ */
+ @Nullable Boolean getBoolean();
+
+ /**
+ * Returns the binary value. The returned array is the underlying message
+ * payload and is not defensively copied; callers must treat it as read-only.
+ *
+ *
Spec: OD2d
+ *
+ * @return the binary value, or {@code null} if not applicable
+ */
+ byte @Nullable [] getBytes();
+
+ /**
+ * Returns the JSON object or array value.
+ *
+ *
Spec: OD2g
+ *
+ * @return the JSON value, or {@code null} if not applicable
+ */
+ @Nullable JsonElement getJson();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectDelete.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectDelete.java
new file mode 100644
index 000000000..1f8c8c671
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectDelete.java
@@ -0,0 +1,13 @@
+package io.ably.lib.liveobjects.message;
+
+/**
+ * Payload of an {@link ObjectOperationAction#OBJECT_DELETE} operation. This type
+ * deliberately has no attributes (ODE2) - the
+ * {@link ObjectOperation#getAction() action} and
+ * {@link ObjectOperation#getObjectId() objectId} are sufficient to describe the
+ * deletion.
+ *
+ *
Spec: ODE1, ODE2
+ */
+public interface ObjectDelete {
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectMessage.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectMessage.java
new file mode 100644
index 000000000..3a2513643
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectMessage.java
@@ -0,0 +1,135 @@
+package io.ably.lib.liveobjects.message;
+
+import com.google.gson.JsonObject;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * The user-facing representation of an inbound object message that carried an operation.
+ * It is delivered to subscription listeners (see
+ * {@link io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent} and
+ * {@link io.ably.lib.liveobjects.instance.InstanceSubscriptionEvent}) so that user code can
+ * inspect the metadata of the message that triggered an object change.
+ *
+ *
An {@code ObjectMessage} always carries an {@link #getOperation() operation}; object
+ * messages without an operation (e.g. sync state messages) are never surfaced to users.
+ *
+ *
This type is the entry point of the {@code io.ably.lib.object.message} package;
+ * all sibling types are reached by walking its properties:
+ *
+ *
{@code
+ * ObjectMessage
+ * └── getOperation() → ObjectOperation
+ * ├── getAction() → ObjectOperationAction (enum)
+ * ├── getMapCreate() → MapCreate → ObjectsMapSemantics, Map → ObjectData
+ * ├── getMapSet() → MapSet → ObjectData
+ * ├── getMapRemove() → MapRemove
+ * ├── getCounterCreate() → CounterCreate
+ * ├── getCounterInc() → CounterInc
+ * ├── getObjectDelete() → ObjectDelete (empty)
+ * └── getMapClear() → MapClear (empty)
+ * }
+ *
+ * Spec: PAOM1, PAOM2
+ */
+public interface ObjectMessage {
+
+ /**
+ * Returns the unique id of the source object message.
+ *
+ *
Spec: PAOM2a / OM2a
+ *
+ * @return the message id, or {@code null} if unavailable
+ */
+ @Nullable String getId();
+
+ /**
+ * Returns the client id of the client that published the source object message.
+ *
+ *
Spec: PAOM2b / OM2b
+ *
+ * @return the client id, or {@code null} if unavailable
+ */
+ @Nullable String getClientId();
+
+ /**
+ * Returns the connection id of the connection from which the source object message
+ * was published.
+ *
+ *
Spec: PAOM2c / OM2c
+ *
+ * @return the connection id, or {@code null} if unavailable
+ */
+ @Nullable String getConnectionId();
+
+ /**
+ * Returns the timestamp of the source object message, as milliseconds since the
+ * epoch.
+ *
+ *
Spec: PAOM2d / OM2e
+ *
+ * @return the timestamp in milliseconds since the epoch, or {@code null} if
+ * unavailable
+ */
+ @Nullable Long getTimestamp();
+
+ /**
+ * Returns the name of the channel on which the source object message was received.
+ *
+ *
Spec: PAOM2e
+ *
+ * @return the channel name
+ */
+ @NotNull String getChannel();
+
+ /**
+ * Returns the operation carried by the source object message.
+ *
+ *
Spec: PAOM2f
+ *
+ * @return the operation that was applied
+ */
+ @NotNull ObjectOperation getOperation();
+
+ /**
+ * Returns the serial of the source object message - an opaque string that uniquely
+ * identifies the operation.
+ *
+ *
Spec: PAOM2g / OM2h
+ *
+ * @return the serial, or {@code null} if unavailable
+ */
+ @Nullable String getSerial();
+
+ /**
+ * Returns the timestamp derived from the {@link #getSerial() serial} of the source
+ * object message, as milliseconds since the epoch.
+ *
+ *
Spec: PAOM2h / OM2j
+ *
+ * @return the serial timestamp in milliseconds since the epoch, or {@code null} if
+ * unavailable
+ */
+ @Nullable Long getSerialTimestamp();
+
+ /**
+ * Returns the site code of the source object message - an opaque string used as a
+ * key to update the map of serial values on an object.
+ *
+ *
Spec: PAOM2i / OM2i
+ *
+ * @return the site code, or {@code null} if unavailable
+ */
+ @Nullable String getSiteCode();
+
+ /**
+ * Returns the extras of the source object message - a JSON-encodable object
+ * containing arbitrary message metadata and/or ancillary payloads. The client
+ * library treats this field opaquely.
+ *
+ *
Spec: PAOM2j / OM2d
+ *
+ * @return the extras, or {@code null} if unavailable
+ */
+ @Nullable JsonObject getExtras();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperation.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperation.java
new file mode 100644
index 000000000..d4df9fb00
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperation.java
@@ -0,0 +1,106 @@
+package io.ably.lib.liveobjects.message;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * The user-facing representation of an operation applied to an object on a channel. It
+ * is exposed as the {@link ObjectMessage#getOperation() operation} attribute of an
+ * {@link ObjectMessage}.
+ *
+ *
Exactly one of the payload accessors ({@link #getMapCreate()},
+ * {@link #getMapSet()}, {@link #getMapRemove()}, {@link #getCounterCreate()},
+ * {@link #getCounterInc()}, {@link #getObjectDelete()}, {@link #getMapClear()}) returns
+ * a non-null value, corresponding to the {@link #getAction() action} of the operation.
+ *
+ *
Note that, unlike the wire-level operation representation, this type does not carry
+ * the outbound-only {@code mapCreateWithObjectId} / {@code counterCreateWithObjectId}
+ * variants: those are resolved back to their derived {@link MapCreate} /
+ * {@link CounterCreate} forms before being surfaced to users.
+ *
+ *
Spec: PAOOP1, PAOOP2
+ */
+public interface ObjectOperation {
+
+ /**
+ * Returns the action of this operation, defining what was applied to the object.
+ *
+ *
Spec: PAOOP2a / OOP3a
+ *
+ * @return the operation action
+ */
+ @NotNull ObjectOperationAction getAction();
+
+ /**
+ * Returns the object id of the object on the channel to which this operation was
+ * applied.
+ *
+ *
Spec: PAOOP2b / OOP3b
+ *
+ * @return the target object id
+ */
+ @NotNull String getObjectId();
+
+ /**
+ * Returns the payload of a {@link ObjectOperationAction#MAP_CREATE} operation.
+ *
+ *
Spec: PAOOP2c / OOP3j
+ *
+ * @return the map-create payload, or {@code null} if not applicable
+ */
+ @Nullable MapCreate getMapCreate();
+
+ /**
+ * Returns the payload of a {@link ObjectOperationAction#MAP_SET} operation.
+ *
+ *
Spec: PAOOP2d / OOP3k
+ *
+ * @return the map-set payload, or {@code null} if not applicable
+ */
+ @Nullable MapSet getMapSet();
+
+ /**
+ * Returns the payload of a {@link ObjectOperationAction#MAP_REMOVE} operation.
+ *
+ *
Spec: PAOOP2e / OOP3l
+ *
+ * @return the map-remove payload, or {@code null} if not applicable
+ */
+ @Nullable MapRemove getMapRemove();
+
+ /**
+ * Returns the payload of a {@link ObjectOperationAction#COUNTER_CREATE} operation.
+ *
+ *
Spec: PAOOP2f / OOP3m
+ *
+ * @return the counter-create payload, or {@code null} if not applicable
+ */
+ @Nullable CounterCreate getCounterCreate();
+
+ /**
+ * Returns the payload of a {@link ObjectOperationAction#COUNTER_INC} operation.
+ *
+ *
Spec: PAOOP2g / OOP3n
+ *
+ * @return the counter-increment payload, or {@code null} if not applicable
+ */
+ @Nullable CounterInc getCounterInc();
+
+ /**
+ * Returns the payload of an {@link ObjectOperationAction#OBJECT_DELETE} operation.
+ *
+ *
Spec: PAOOP2h / OOP3o
+ *
+ * @return the object-delete payload, or {@code null} if not applicable
+ */
+ @Nullable ObjectDelete getObjectDelete();
+
+ /**
+ * Returns the payload of a {@link ObjectOperationAction#MAP_CLEAR} operation.
+ *
+ *
Spec: PAOOP2i / OOP3r
+ *
+ * @return the map-clear payload, or {@code null} if not applicable
+ */
+ @Nullable MapClear getMapClear();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperationAction.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperationAction.java
new file mode 100644
index 000000000..002f289d4
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectOperationAction.java
@@ -0,0 +1,37 @@
+package io.ably.lib.liveobjects.message;
+
+/**
+ * The action of an {@link ObjectOperation}, defining the type of operation that was
+ * applied to an object on a channel.
+ *
+ *
Spec: OOP2 / PAOOP2a
+ */
+public enum ObjectOperationAction {
+
+ /** Creates a new {@code LiveMap} object. Spec: OOP2 */
+ MAP_CREATE,
+
+ /** Sets the value at a key of a {@code LiveMap} object. Spec: OOP2 */
+ MAP_SET,
+
+ /** Removes a key from a {@code LiveMap} object. Spec: OOP2 */
+ MAP_REMOVE,
+
+ /** Creates a new {@code LiveCounter} object. Spec: OOP2 */
+ COUNTER_CREATE,
+
+ /** Increments the value of a {@code LiveCounter} object. Spec: OOP2 */
+ COUNTER_INC,
+
+ /** Deletes (tombstones) an object. Spec: OOP2 */
+ OBJECT_DELETE,
+
+ /** Removes all entries from a {@code LiveMap} object. Spec: OOP2 */
+ MAP_CLEAR,
+
+ /**
+ * Future-compatibility fallback for an action not recognized by this version of
+ * the client library.
+ */
+ UNKNOWN,
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapEntry.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapEntry.java
new file mode 100644
index 000000000..09df91fbb
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapEntry.java
@@ -0,0 +1,51 @@
+package io.ably.lib.liveobjects.message;
+
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Represents the value at a given key in a {@code LiveMap} object.
+ *
+ *
Spec: ME1
+ */
+public interface ObjectsMapEntry {
+
+ /**
+ * Indicates whether the map entry has been removed.
+ *
+ *
Spec: OME2a
+ *
+ * @return {@code true} if the entry is tombstoned, or {@code null} if unavailable
+ */
+ @Nullable Boolean getTombstone();
+
+ /**
+ * Returns the serial value of the latest operation that was applied to the map
+ * entry.
+ *
+ *
Spec: OME2b
+ *
+ * @return the entry timeserial, or {@code null} if unavailable
+ */
+ @Nullable String getTimeserial();
+
+ /**
+ * Returns the timestamp derived from the {@link #getTimeserial() timeserial} of
+ * this entry, as milliseconds since the epoch. Only present if
+ * {@link #getTombstone()} is {@code true}.
+ *
+ *
Spec: OME2d
+ *
+ * @return the serial timestamp in milliseconds since the epoch, or {@code null} if
+ * unavailable
+ */
+ @Nullable Long getSerialTimestamp();
+
+ /**
+ * Returns the data that represents the value of the map entry.
+ *
+ *
Spec: OME2c
+ *
+ * @return the entry value, or {@code null} if unavailable
+ */
+ @Nullable ObjectData getData();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapSemantics.java b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapSemantics.java
new file mode 100644
index 000000000..1fcb7f2aa
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/message/ObjectsMapSemantics.java
@@ -0,0 +1,18 @@
+package io.ably.lib.liveobjects.message;
+
+/**
+ * The conflict-resolution semantics used by a {@code LiveMap} object.
+ *
+ *
Spec: OMP2
+ */
+public enum ObjectsMapSemantics {
+
+ /** Last-write-wins conflict resolution. Spec: OMP2a */
+ LWW,
+
+ /**
+ * Future-compatibility fallback for semantics not known to this version of the
+ * client library.
+ */
+ UNKNOWN,
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/message/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/message/package-info.java
new file mode 100644
index 000000000..f3f7c246e
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/message/package-info.java
@@ -0,0 +1,26 @@
+/**
+ * User-facing object message metadata, delivered to subscription listeners so
+ * that user code can inspect the operation that triggered an object change.
+ *
+ *
{@link io.ably.lib.liveobjects.message.ObjectMessage} is the single entry point
+ * of this package; every other type is reached by walking its properties:
+ *
+ *
{@code
+ * ObjectMessage (delivered in subscription events)
+ * └── getOperation() → ObjectOperation
+ * ├── getAction() → ObjectOperationAction (enum)
+ * ├── getMapCreate() → MapCreate
+ * │ ├── getSemantics() → ObjectsMapSemantics (enum)
+ * │ └── getEntries() → Map
+ * │ └── getData() → ObjectData
+ * ├── getMapSet() → MapSet ── getValue() → ObjectData
+ * ├── getMapRemove() → MapRemove
+ * ├── getCounterCreate() → CounterCreate
+ * ├── getCounterInc() → CounterInc
+ * ├── getObjectDelete() → ObjectDelete (empty)
+ * └── getMapClear() → MapClear (empty)
+ * }
+ *
+ * Spec: PAOM1-PAOM3, PAOOP1-PAOOP3
+ */
+package io.ably.lib.liveobjects.message;
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/package-info.java
new file mode 100644
index 000000000..722ac3994
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/package-info.java
@@ -0,0 +1,17 @@
+/**
+ * The public, strongly-typed LiveObjects API: path-based and instance-based views
+ * over the objects graph on a channel.
+ *
+ *
This root package holds the types shared by both view hierarchies:
+ * {@link io.ably.lib.liveobjects.ValueType} (the categories a resolved value may have)
+ * and {@link io.ably.lib.liveobjects.Subscription} (the handle returned by every
+ * {@code subscribe} operation). The hierarchies themselves live in
+ * {@link io.ably.lib.liveobjects.path} (lazy, path-addressed references) and
+ * {@link io.ably.lib.liveobjects.instance} (O(1), identity-addressed references);
+ * message metadata delivered to subscription listeners lives in
+ * {@link io.ably.lib.liveobjects.message}, and write-side value types in
+ * {@link io.ably.lib.liveobjects.value}.
+ *
+ *
Spec: RTTS1-RTTS10 (typed-SDK public API partition)
+ */
+package io.ably.lib.liveobjects;
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/PathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObject.java
new file mode 100644
index 000000000..36a2ec49d
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObject.java
@@ -0,0 +1,236 @@
+package io.ably.lib.liveobjects.path;
+
+import com.google.gson.JsonElement;
+import io.ably.lib.liveobjects.ValueType;
+import io.ably.lib.liveobjects.instance.Instance;
+import io.ably.lib.liveobjects.path.types.BinaryPathObject;
+import io.ably.lib.liveobjects.path.types.BooleanPathObject;
+import io.ably.lib.liveobjects.path.types.JsonArrayPathObject;
+import io.ably.lib.liveobjects.path.types.JsonObjectPathObject;
+import io.ably.lib.liveobjects.path.types.LiveCounterPathObject;
+import io.ably.lib.liveobjects.path.types.LiveMapPathObject;
+import io.ably.lib.liveobjects.path.types.NumberPathObject;
+import io.ably.lib.liveobjects.path.types.StringPathObject;
+import io.ably.lib.liveobjects.Subscription;
+import org.jetbrains.annotations.NonBlocking;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A lazy, path-based reference into the LiveObjects graph rooted at the channel's root
+ * {@code LiveMap}.
+ *
+ *
A {@code PathObject} stores a path as an ordered list of string segments and
+ * resolves it against the local object graph each time a terminal method is called;
+ * the freshly resolved value is the sole basis for that call's result. Resolution is
+ * best-effort: the value at a path may change between two calls (e.g. between
+ * {@link #exists()} and a subsequent write) as updates from other clients are applied.
+ *
+ *
When the path does not resolve, or resolves to a type the called method does not
+ * apply to, read operations degrade gracefully - returning {@code null} or an empty
+ * result - whereas write operations fail with an {@code AblyException} (code 92005 if
+ * the path does not resolve, 92007 on a type mismatch). All terminal operations
+ * additionally validate the access/write API preconditions and fail with an
+ * {@code AblyException} if those are not satisfied.
+ *
+ *
This base type exposes only the methods whose behaviour is independent of the
+ * resolved type; map and counter reads/writes are partitioned onto the sub-types
+ * (RTTS3e). Use the {@code as*} helpers to obtain a sub-type view without type
+ * validation, e.g. {@code pathObject.asLiveMap().at("a.b.c")} (RTTS3g). The spec's
+ * {@code compact} is not exposed; {@link #compactJson()} is the supported equivalent
+ * (RTTS3f).
+ *
+ *
Spec: RTPO1, RTPO2, RTTS3
+ *
+ * @see LiveMapPathObject
+ * @see LiveCounterPathObject
+ * @see PathObjectListener
+ */
+public interface PathObject {
+
+ /**
+ * Returns the {@link ValueType} of the value currently resolved at this path, or
+ * {@code null} when the path does not resolve to any value. Use this instead of
+ * dedicated {@code isLiveMap}/{@code isLiveCounter}/etc. checks.
+ *
+ *
A {@code null} result means there is no value at this path - nothing is stored
+ * there (e.g. an absent or removed map entry). This is deliberately distinct from
+ * {@link ValueType#UNKNOWN}, which is returned only when a value is present
+ * but its type matches none of the known categories. In other words: {@code null}
+ * means "no value", {@code UNKNOWN} means "a value of an unrecognized type".
+ *
+ *
Spec: RTTS4b
+ *
+ * @return the resolved value type at this path, or {@code null} if the path does
+ * not resolve to a value
+ */
+ @Nullable ValueType getType();
+
+ /**
+ * Returns a dot-delimited string representation of the stored path segments.
+ * Dot characters inside individual segments are escaped with a backslash, so a
+ * path with segments {@code ["a", "b.c", "d"]} is represented as {@code "a.b\.c.d"}.
+ * An empty path (i.e. the root {@code PathObject}) returns the empty string.
+ *
+ *
Spec: RTPO4 / RTTS3a
+ *
+ * @return the dot-delimited path from the root to this position
+ */
+ @NotNull String path();
+
+ /**
+ * Resolves this path and returns a {@link Instance} wrapping the underlying
+ * value if it is a {@code LiveMap} or {@code LiveCounter}.
+ *
+ *
Returns {@code null} when the resolved value is a primitive (LiveObjects with
+ * no object id), when the path does not resolve, or when called on primitive
+ * {@code *PathObject} sub-types.
+ *
+ *
Spec: RTPO8 / RTTS3b
+ *
+ * @return a {@link Instance} wrapping the resolved live object, or {@code null}
+ */
+ @Nullable Instance instance();
+
+ /**
+ * Returns a JSON-serializable, recursively compacted snapshot of the value at this
+ * path. Behaves like the spec's {@code compact} except that {@code Binary} values
+ * are base64-encoded and cyclic references are represented as
+ * {@code { "objectId": ... }} markers, so the result is safe to serialise as JSON.
+ *
+ *
Returns {@code null} when the path does not resolve.
+ *
+ *
Spec: RTPO14 / RTTS3c
+ *
+ * @return the compacted JSON snapshot, or {@code null} if the path does not resolve
+ */
+ @Nullable JsonElement compactJson();
+
+ /**
+ * Returns {@code true} if a value currently resolves at this path in the local
+ * object graph. This is a best-effort check evaluated at call time; the answer may
+ * change immediately afterwards as remote operations are applied. Useful as a
+ * guard before performing operations whose semantics depend on existence.
+ *
+ *
Complexity is O(n) in the path length because the path must be resolved.
+ *
+ *
Spec: RTTS4a
+ *
+ * @return {@code true} if the path resolves to a value, {@code false} otherwise
+ */
+ boolean exists();
+
+ /**
+ * Returns this {@code PathObject} wrapped as a {@link LiveMapPathObject}.
+ *
+ *
This is a best-effort cast - it does not validate that the underlying value
+ * at this path is a {@code LiveMap}. Read operations are always permitted on the
+ * returned wrapper; write or terminal operations that require resolution will fail
+ * at call time if the resolved value is not a {@code LiveMap}.
+ *
+ *
Spec: RTTS5a
+ *
+ * @return a {@link LiveMapPathObject} view of this path
+ */
+ @NotNull LiveMapPathObject asLiveMap();
+
+ /**
+ * Returns this {@code PathObject} wrapped as a {@link LiveCounterPathObject}.
+ * Best-effort cast; does not validate the underlying type at this path.
+ *
+ *
Spec: RTTS5b
+ *
+ * @return a {@link LiveCounterPathObject} view of this path
+ */
+ @NotNull LiveCounterPathObject asLiveCounter();
+
+ /**
+ * Returns this {@code PathObject} wrapped as a {@link NumberPathObject}.
+ * Best-effort cast; does not validate the underlying type at this path.
+ *
+ *
Spec: RTTS5c
+ *
+ * @return a {@link NumberPathObject} view of this path
+ */
+ @NotNull NumberPathObject asNumber();
+
+ /**
+ * Returns this {@code PathObject} wrapped as a {@link StringPathObject}.
+ * Best-effort cast; does not validate the underlying type at this path.
+ *
+ *
Spec: RTTS5c
+ *
+ * @return a {@link StringPathObject} view of this path
+ */
+ @NotNull StringPathObject asString();
+
+ /**
+ * Returns this {@code PathObject} wrapped as a {@link BooleanPathObject}.
+ * Best-effort cast; does not validate the underlying type at this path.
+ *
+ *
Spec: RTTS5c
+ *
+ * @return a {@link BooleanPathObject} view of this path
+ */
+ @NotNull BooleanPathObject asBoolean();
+
+ /**
+ * Returns this {@code PathObject} wrapped as a {@link BinaryPathObject}.
+ * Best-effort cast; does not validate the underlying type at this path.
+ *
+ *
Spec: RTTS5c
+ *
+ * @return a {@link BinaryPathObject} view of this path
+ */
+ @NotNull BinaryPathObject asBinary();
+
+ /**
+ * Returns this {@code PathObject} wrapped as a {@link JsonObjectPathObject}.
+ * Best-effort cast; does not validate the underlying type at this path.
+ *
+ *
Spec: RTTS5c
+ *
+ * @return a {@link JsonObjectPathObject} view of this path
+ */
+ @NotNull JsonObjectPathObject asJsonObject();
+
+ /**
+ * Returns this {@code PathObject} wrapped as a {@link JsonArrayPathObject}.
+ * Best-effort cast; does not validate the underlying type at this path.
+ *
+ *
Spec: RTTS5c
+ *
+ * @return a {@link JsonArrayPathObject} view of this path
+ */
+ @NotNull JsonArrayPathObject asJsonArray();
+
+ /**
+ * Subscribes a listener for path-based update events. The listener is invoked when
+ * an operation modifies the value at this path. The same path may be subscribed by
+ * multiple listeners independently. Call {@link Subscription#unsubscribe()}
+ * on the returned handle to stop receiving events for this listener.
+ *
+ *
Spec: RTPO19 / RTTS3d
+ *
+ * @param listener the listener to invoke on updates
+ * @return a subscription handle that can be used to unsubscribe this listener
+ */
+ @NonBlocking
+ @NotNull Subscription subscribe(@NotNull PathObjectListener listener);
+
+ /**
+ * Subscribes a listener for path-based update events using the provided
+ * {@link PathObjectSubscriptionOptions}. Options control coverage rules such as the
+ * {@code depth} of nested updates that trigger the listener. Call
+ * {@link Subscription#unsubscribe()} on the returned handle to stop
+ * receiving events for this listener.
+ *
+ *
Spec: RTPO19 / RTTS3d
+ *
+ * @param listener the listener to invoke on updates
+ * @param options optional subscription options, may be {@code null}
+ * @return a subscription handle that can be used to unsubscribe this listener
+ */
+ @NonBlocking
+ @NotNull Subscription subscribe(@NotNull PathObjectListener listener, @Nullable PathObjectSubscriptionOptions options);
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectListener.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectListener.java
new file mode 100644
index 000000000..48bca1c72
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectListener.java
@@ -0,0 +1,21 @@
+package io.ably.lib.liveobjects.path;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Listener interface for path-based subscriptions created via
+ * {@link PathObject#subscribe(PathObjectListener)} or
+ * {@link PathObject#subscribe(PathObjectListener, PathObjectSubscriptionOptions)}.
+ *
+ *
Spec: RTPO19a1
+ */
+public interface PathObjectListener {
+
+ /**
+ * Invoked when a change is applied at, or beneath, the subscribed path according
+ * to the configured {@link PathObjectSubscriptionOptions}.
+ *
+ * @param event the event describing the change
+ */
+ void onUpdated(@NotNull PathObjectSubscriptionEvent event);
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionEvent.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionEvent.java
new file mode 100644
index 000000000..7fd978de9
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionEvent.java
@@ -0,0 +1,34 @@
+package io.ably.lib.liveobjects.path;
+
+import io.ably.lib.liveobjects.message.ObjectMessage;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Event delivered to {@link PathObjectListener#onUpdated(PathObjectSubscriptionEvent)}
+ * when a change affects the subscribed path.
+ *
+ *
Spec: RTPO19e / RTTS3d
+ */
+public interface PathObjectSubscriptionEvent {
+
+ /**
+ * Returns a {@link PathObject} pointing to the path where the change occurred.
+ *
+ *
Spec: RTPO19e1
+ *
+ * @return the {@code PathObject} at the changed path
+ */
+ @NotNull PathObject getObject();
+
+ /**
+ * Returns the {@link ObjectMessage} describing the operation that caused this
+ * event, if any. The value is present whenever the underlying update carried
+ * an object message with an operation; otherwise it is {@code null}.
+ *
+ *
Spec: RTPO19e2 / PAOM1
+ *
+ * @return the source {@code ObjectMessage}, or {@code null} if unavailable
+ */
+ @Nullable ObjectMessage getMessage();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionOptions.java b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionOptions.java
new file mode 100644
index 000000000..df9b5a9fa
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/PathObjectSubscriptionOptions.java
@@ -0,0 +1,58 @@
+package io.ably.lib.liveobjects.path;
+
+import io.ably.lib.types.AblyException;
+import io.ably.lib.types.ErrorInfo;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Optional subscription options accepted by
+ * {@link PathObject#subscribe(PathObjectListener, PathObjectSubscriptionOptions)}.
+ *
+ *
Spec: RTPO19c
+ */
+public final class PathObjectSubscriptionOptions {
+
+ private final Integer depth;
+
+ /**
+ * Creates options with no {@code depth} set: there is no depth limit, and
+ * changes at any depth within nested children trigger the listener.
+ * Equivalent to passing a {@code null} depth.
+ *
+ *
Spec: RTPO19c1
+ */
+ public PathObjectSubscriptionOptions() {
+ this.depth = null;
+ }
+
+ /**
+ * Creates options with the given {@code depth}. For infinite depth, use the
+ * no-arg constructor {@link #PathObjectSubscriptionOptions()} instead.
+ *
+ *
Spec: RTPO19c1, RTPO19c1a
+ *
+ * @param depth how many levels of path nesting below the subscribed path should
+ * trigger the listener; must be a positive integer
+ * @throws AblyException with {@code statusCode} 400 and {@code code} 40003 if
+ * {@code depth} is not a positive integer
+ */
+ public PathObjectSubscriptionOptions(int depth) throws AblyException {
+ if (depth <= 0) {
+ throw AblyException.fromErrorInfo(
+ new ErrorInfo("Subscription depth must be greater than 0 or omitted for infinite depth", 400, 40003));
+ }
+ this.depth = depth;
+ }
+
+ /**
+ * Returns the configured nesting depth, or {@code null} if not set.
+ *
+ *
Spec: RTPO19c1
+ *
+ * @return the depth value, or {@code null}
+ */
+ @Nullable
+ public Integer getDepth() {
+ return depth;
+ }
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/path/package-info.java
new file mode 100644
index 000000000..00471f2fe
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/package-info.java
@@ -0,0 +1,13 @@
+/**
+ * The path-addressed view of the LiveObjects graph.
+ * {@link io.ably.lib.liveobjects.path.PathObject} stores a path from the channel's
+ * root {@code LiveMap} and re-resolves it lazily on every call, so a reference
+ * survives object replacement at its path. Type-specific operations live on the
+ * sub-types in {@link io.ably.lib.liveobjects.path.types}; path-based subscriptions
+ * use {@link io.ably.lib.liveobjects.path.PathObjectListener},
+ * {@link io.ably.lib.liveobjects.path.PathObjectSubscriptionEvent} and
+ * {@link io.ably.lib.liveobjects.path.PathObjectSubscriptionOptions}.
+ *
+ *
Spec: RTPO1-RTPO19, RTTS3-RTTS5
+ */
+package io.ably.lib.liveobjects.path;
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/types/BinaryPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BinaryPathObject.java
new file mode 100644
index 000000000..e96ac7062
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BinaryPathObject.java
@@ -0,0 +1,29 @@
+package io.ably.lib.liveobjects.path.types;
+
+import io.ably.lib.liveobjects.path.PathObject;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A {@link PathObject} whose underlying value is expected to be a binary blob
+ * (a {@code byte[]}).
+ *
+ *
This is a terminal type. {@link PathObject#instance()} returns {@code null}
+ * because a primitive resolution does not produce a wrapped LiveObject; navigation
+ * via {@code at(...)} is not available here because it is only defined on
+ * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are
+ * useful here.
+ *
+ *
Spec: RTTS6c
+ */
+public interface BinaryPathObject extends PathObject {
+
+ /**
+ * Returns the binary value at this path, or {@code null} when the path does not
+ * resolve or resolves to a non-binary value.
+ *
+ *
Spec: RTPO7 / RTTS6c
+ *
+ * @return the resolved bytes, or {@code null}
+ */
+ byte @Nullable [] value();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/types/BooleanPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BooleanPathObject.java
new file mode 100644
index 000000000..c77c59f9e
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/BooleanPathObject.java
@@ -0,0 +1,29 @@
+package io.ably.lib.liveobjects.path.types;
+
+import io.ably.lib.liveobjects.path.PathObject;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A {@link PathObject} whose underlying value is expected to be a {@code Boolean}.
+ *
+ *
This is a terminal type. {@link PathObject#instance()} returns {@code null}
+ * because a primitive resolution does not produce a wrapped LiveObject; navigation
+ * via {@code at(...)} is not available here because it is only defined on
+ * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are
+ * useful here.
+ *
+ *
Spec: RTTS6c
+ */
+public interface BooleanPathObject extends PathObject {
+
+ /**
+ * Returns the boolean at this path, or {@code null} when the path does not resolve
+ * or resolves to a non-boolean value.
+ *
+ *
Spec: RTPO7 / RTTS6c
+ *
+ * @return the resolved boolean, or {@code null}
+ */
+ @Nullable
+ Boolean value();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonArrayPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonArrayPathObject.java
new file mode 100644
index 000000000..52a89f016
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonArrayPathObject.java
@@ -0,0 +1,30 @@
+package io.ably.lib.liveobjects.path.types;
+
+import com.google.gson.JsonArray;
+import io.ably.lib.liveobjects.path.PathObject;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A {@link PathObject} whose underlying value is expected to be a {@link JsonArray}.
+ *
+ *
This is a terminal type. {@link PathObject#instance()} returns {@code null}
+ * because this resolution does not produce a wrapped LiveObject instance; navigation
+ * via {@code at(...)} is not available here because it is only defined on
+ * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are
+ * useful here.
+ *
+ *
Spec: RTTS6c
+ */
+public interface JsonArrayPathObject extends PathObject {
+
+ /**
+ * Returns the JSON array at this path, or {@code null} when the path does not
+ * resolve or resolves to a non-JsonArray value.
+ *
+ *
Spec: RTPO7 / RTTS6c
+ *
+ * @return the resolved JsonArray, or {@code null}
+ */
+ @Nullable
+ JsonArray value();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonObjectPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonObjectPathObject.java
new file mode 100644
index 000000000..b889ab521
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/JsonObjectPathObject.java
@@ -0,0 +1,30 @@
+package io.ably.lib.liveobjects.path.types;
+
+import com.google.gson.JsonObject;
+import io.ably.lib.liveobjects.path.PathObject;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A {@link PathObject} whose underlying value is expected to be a {@link JsonObject}.
+ *
+ *
This is a terminal type. {@link PathObject#instance()} returns {@code null}
+ * because this resolution does not produce a wrapped LiveObject instance; navigation
+ * via {@code at(...)} is not available here because it is only defined on
+ * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are
+ * useful here.
+ *
+ *
Spec: RTTS6c
+ */
+public interface JsonObjectPathObject extends PathObject {
+
+ /**
+ * Returns the JSON object at this path, or {@code null} when the path does not
+ * resolve or resolves to a non-JsonObject value.
+ *
+ *
Spec: RTPO7 / RTTS6c
+ *
+ * @return the resolved JsonObject, or {@code null}
+ */
+ @Nullable
+ JsonObject value();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveCounterPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveCounterPathObject.java
new file mode 100644
index 000000000..fb8eb87d3
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveCounterPathObject.java
@@ -0,0 +1,87 @@
+package io.ably.lib.liveobjects.path.types;
+
+import io.ably.lib.liveobjects.path.PathObject;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * A {@link PathObject} whose underlying value is expected to be a {@code LiveCounter}.
+ * Provides type-safe access to counter operations such as {@link #value()},
+ * {@link #increment(Number)} and {@link #decrement(Number)}.
+ *
+ *
Counters are terminal nodes - navigation via {@code at(...)} is not available
+ * here because it is only defined on {@code LiveMapPathObject}.
+ *
+ *
Operations are best-effort and resolve the path at call time. Read operations
+ * return {@code null} when the path does not resolve to a {@code LiveCounter}; write
+ * operations complete the returned {@link CompletableFuture} exceptionally with an
+ * {@code AblyException} (status 400, code 92007) in that case.
+ *
+ *
Spec: RTTS6b
+ */
+public interface LiveCounterPathObject extends PathObject {
+
+ /**
+ * Returns the current value of the {@code LiveCounter} at this path, or {@code null}
+ * when the path does not resolve to a {@code LiveCounter}.
+ *
+ *
Spec: RTPO7 / RTLC5
+ *
+ * @return the counter value, or {@code null}
+ */
+ @Nullable
+ Double value();
+
+ /**
+ * Increments the {@code LiveCounter} at this path by {@code 1}. Equivalent to
+ * calling {@link #increment(Number)} with {@code 1}.
+ *
+ *
Spec: RTPO17a1 (default {@code amount} of {@code 1})
+ *
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture increment();
+
+ /**
+ * Increments the {@code LiveCounter} at this path by {@code amount}.
+ *
+ * Sends a {@code COUNTER_INC} operation to the realtime system; the local state
+ * is updated when the operation is echoed back. The returned future completes
+ * exceptionally with an {@code AblyException} (status 400, code 92005) if the path
+ * cannot be resolved, or (status 400, code 92007) if the resolved value is not a
+ * {@code LiveCounter}.
+ *
+ *
Spec: RTPO17
+ *
+ * @param amount the amount to add (may be negative)
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture increment(@NotNull Number amount);
+
+ /**
+ * Decrements the {@code LiveCounter} at this path by {@code 1}. Equivalent to
+ * calling {@link #decrement(Number)} with {@code 1}.
+ *
+ * Spec: RTPO18a1 (default {@code amount} of {@code 1})
+ *
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture decrement();
+
+ /**
+ * Decrements the {@code LiveCounter} at this path by {@code amount}. Equivalent to
+ * calling {@link #increment(Number)} with a negated value.
+ *
+ * Spec: RTPO18
+ *
+ * @param amount the amount to subtract (may be negative)
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture decrement(@NotNull Number amount);
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveMapPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveMapPathObject.java
new file mode 100644
index 000000000..cf35e553f
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/LiveMapPathObject.java
@@ -0,0 +1,148 @@
+package io.ably.lib.liveobjects.path.types;
+
+import io.ably.lib.liveobjects.path.PathObject;
+import io.ably.lib.liveobjects.value.LiveMapValue;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.Unmodifiable;
+
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * A {@link PathObject} whose underlying value is expected to be a {@code LiveMap}.
+ * Provides type-safe access to map-specific operations such as {@link #get(String)},
+ * {@link #entries()}, {@link #set(String, LiveMapValue)}, etc.
+ *
+ * Calling {@code channel.objects.getRoot()}-equivalent navigation methods at the
+ * root of the graph always returns a {@code LiveMapPathObject}.
+ *
+ *
Operations on this type are best-effort: they resolve the path against the local
+ * LiveObjects graph at call time. Read operations return empty/null when the path does
+ * not resolve to a {@code LiveMap}; write operations complete the returned
+ * {@link CompletableFuture} exceptionally with an {@code AblyException}
+ * (status 400, code 92007) in that case.
+ *
+ *
Spec: RTTS6a
+ */
+public interface LiveMapPathObject extends PathObject {
+
+ /**
+ * Returns a new {@link PathObject} representing the child at {@code key} of the
+ * {@code LiveMap} at this path. Purely navigational - no resolution occurs.
+ *
+ *
Spec: RTPO5
+ *
+ * @param key the child key to navigate to
+ * @return a {@link PathObject} pointing to {@code this.path + key}
+ */
+ @NotNull
+ PathObject get(@NotNull String key);
+
+ /**
+ * Returns a new {@link PathObject} whose path is this path with the segments parsed
+ * from {@code path} appended. The {@code path} argument is a dot-delimited string;
+ * a backslash-escaped dot ({@code \.}) is treated as a literal dot within a segment.
+ *
+ *
This is purely navigational - no resolution against the LiveObjects graph is
+ * performed by this call. {@code liveMapPath.at("a.b.c")} is equivalent to
+ * {@code liveMapPath.get("a").asLiveMap().get("b").asLiveMap().get("c")}.
+ *
+ *
Available only on {@code LiveMapPathObject} because deeper navigation is only
+ * meaningful when the current resolved value is a {@code LiveMap}. To traverse from
+ * an arbitrary {@link PathObject}, first cast via {@link PathObject#asLiveMap()}.
+ *
+ *
Spec: RTPO6
+ *
+ * @param path dot-delimited path to append to this path
+ * @return a new {@link PathObject} representing the deeper path
+ */
+ @NotNull
+ PathObject at(@NotNull String path);
+
+ /**
+ * Returns the entries (key, child {@link PathObject}) of the {@code LiveMap} at
+ * this path. Each child path is produced as if by calling {@link #get(String)} with
+ * the corresponding key.
+ *
+ *
Returns an empty iterable when the path does not resolve to a {@code LiveMap}.
+ *
+ *
Spec: RTPO9
+ *
+ * @return an unmodifiable iterable of map entries; empty when not a LiveMap
+ */
+ @NotNull
+ @Unmodifiable
+ Iterable> entries();
+
+ /**
+ * Returns the keys of the {@code LiveMap} at this path.
+ *
+ * Returns an empty iterable when the path does not resolve to a {@code LiveMap}.
+ *
+ *
Spec: RTPO10
+ *
+ * @return an unmodifiable iterable of keys; empty when not a LiveMap
+ */
+ @NotNull
+ @Unmodifiable
+ Iterable keys();
+
+ /**
+ * Returns the child {@link PathObject}s for each key in the {@code LiveMap} at this
+ * path.
+ *
+ * Returns an empty iterable when the path does not resolve to a {@code LiveMap}.
+ *
+ *
Spec: RTPO11
+ *
+ * @return an unmodifiable iterable of child paths; empty when not a LiveMap
+ */
+ @NotNull
+ @Unmodifiable
+ Iterable values();
+
+ /**
+ * Returns the size of the {@code LiveMap} at this path, or {@code null} when the
+ * path does not resolve to a {@code LiveMap}.
+ *
+ * Spec: RTPO12
+ *
+ * @return the number of (non-tombstoned) entries, or {@code null}
+ */
+ @Nullable
+ Long size();
+
+ /**
+ * Sets a key on the {@code LiveMap} at this path to the provided value.
+ *
+ *
Sends a {@code MAP_SET} operation to the realtime system; the local state is
+ * updated when the operation is echoed back. The returned future completes
+ * exceptionally with an {@code AblyException} (status 400, code 92005) if the path
+ * cannot be resolved, or (status 400, code 92007) if the resolved value is not a
+ * {@code LiveMap}.
+ *
+ *
Spec: RTPO15
+ *
+ * @param key the key to set
+ * @param value the value to associate with {@code key}
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture set(@NotNull String key, @NotNull LiveMapValue value);
+
+ /**
+ * Removes a key from the {@code LiveMap} at this path.
+ *
+ * Sends a {@code MAP_REMOVE} operation to the realtime system; the local state
+ * is updated when the operation is echoed back. Same error conditions as
+ * {@link #set(String, LiveMapValue)} apply.
+ *
+ *
Spec: RTPO16
+ *
+ * @param key the key to remove
+ * @return a future that completes when the operation has been acknowledged
+ */
+ @NotNull
+ CompletableFuture remove(@NotNull String key);
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/types/NumberPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/NumberPathObject.java
new file mode 100644
index 000000000..6eadd3697
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/NumberPathObject.java
@@ -0,0 +1,29 @@
+package io.ably.lib.liveobjects.path.types;
+
+import io.ably.lib.liveobjects.path.PathObject;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A {@link PathObject} whose underlying value is expected to be a {@code Number}.
+ *
+ * This is a terminal type. {@link PathObject#instance()} returns {@code null}
+ * because a primitive resolution does not produce a wrapped LiveObject; navigation
+ * via {@code at(...)} is not available here because it is only defined on
+ * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are
+ * useful here.
+ *
+ *
Spec: RTTS6c
+ */
+public interface NumberPathObject extends PathObject {
+
+ /**
+ * Returns the number at this path, or {@code null} when the path does not resolve
+ * or resolves to a non-numeric value.
+ *
+ *
Spec: RTPO7 / RTTS6c
+ *
+ * @return the resolved number, or {@code null}
+ */
+ @Nullable
+ Number value();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/types/StringPathObject.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/StringPathObject.java
new file mode 100644
index 000000000..18652dc03
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/StringPathObject.java
@@ -0,0 +1,29 @@
+package io.ably.lib.liveobjects.path.types;
+
+import io.ably.lib.liveobjects.path.PathObject;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A {@link PathObject} whose underlying value is expected to be a {@code String}.
+ *
+ *
This is a terminal type. {@link PathObject#instance()} returns {@code null}
+ * because a primitive resolution does not produce a wrapped LiveObject; navigation
+ * via {@code at(...)} is not available here because it is only defined on
+ * {@code LiveMapPathObject}. Only {@link #value()} and the inherited read APIs are
+ * useful here.
+ *
+ *
Spec: RTTS6c
+ */
+public interface StringPathObject extends PathObject {
+
+ /**
+ * Returns the string at this path, or {@code null} when the path does not resolve
+ * or resolves to a non-string value.
+ *
+ *
Spec: RTPO7 / RTTS6c
+ *
+ * @return the resolved string, or {@code null}
+ */
+ @Nullable
+ String value();
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/path/types/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/path/types/package-info.java
new file mode 100644
index 000000000..1575cfabc
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/path/types/package-info.java
@@ -0,0 +1,11 @@
+/**
+ * Type-specific {@code PathObject} sub-types: the typed-SDK partition of path
+ * operations. {@link io.ably.lib.liveobjects.path.types.LiveMapPathObject} (RTTS6a)
+ * carries map navigation and writes,
+ * {@link io.ably.lib.liveobjects.path.types.LiveCounterPathObject} (RTTS6b) carries
+ * counter operations, and the six primitive sub-types (RTTS6c) expose only a
+ * type-narrowed {@code value()}.
+ *
+ *
Spec: RTTS6
+ */
+package io.ably.lib.liveobjects.path.types;
diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectJsonSerializer.java
similarity index 72%
rename from lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java
rename to lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectJsonSerializer.java
index b96954ca8..c6f95d200 100644
--- a/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java
+++ b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectJsonSerializer.java
@@ -1,4 +1,4 @@
-package io.ably.lib.objects;
+package io.ably.lib.liveobjects.serialization;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
@@ -11,14 +11,14 @@
import java.lang.reflect.Type;
-public class ObjectsJsonSerializer implements JsonSerializer, JsonDeserializer {
- private static final String TAG = ObjectsJsonSerializer.class.getName();
+public class ObjectJsonSerializer implements JsonSerializer, JsonDeserializer {
+ private static final String TAG = ObjectJsonSerializer.class.getName();
@Override
public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
- ObjectsSerializer serializer = ObjectsHelper.getSerializer();
+ ObjectSerializer serializer = ObjectSerializer.tryGet();
if (serializer == null) {
- Log.w(TAG, "Skipping 'state' field json deserialization because ObjectsSerializer not found.");
+ Log.w(TAG, "Skipping 'state' field json deserialization because ObjectSerializer not found.");
return null;
}
if (!json.isJsonArray()) {
@@ -29,9 +29,9 @@ public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationC
@Override
public JsonElement serialize(Object[] src, Type typeOfSrc, JsonSerializationContext context) {
- ObjectsSerializer serializer = ObjectsHelper.getSerializer();
+ ObjectSerializer serializer = ObjectSerializer.tryGet();
if (serializer == null) {
- Log.w(TAG, "Skipping 'state' field json serialization because ObjectsSerializer not found.");
+ Log.w(TAG, "Skipping 'state' field json serialization because ObjectSerializer not found.");
return JsonNull.INSTANCE;
}
return serializer.asJsonArray(src);
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectSerializer.java b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectSerializer.java
new file mode 100644
index 000000000..c5b6abcf3
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/serialization/ObjectSerializer.java
@@ -0,0 +1,98 @@
+package io.ably.lib.liveobjects.serialization;
+
+import com.google.gson.JsonArray;
+import io.ably.lib.util.Log;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.msgpack.core.MessagePacker;
+import org.msgpack.core.MessageUnpacker;
+
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+
+/**
+ * Serializer interface for converting between objects and their MessagePack or JSON representations.
+ */
+public interface ObjectSerializer {
+
+ /**
+ * Reads a MessagePack array from the given unpacker and deserializes it into an Object array.
+ *
+ * @param unpacker the MessageUnpacker to read from
+ * @return the deserialized Object array
+ * @throws IOException if an I/O error occurs during unpacking
+ */
+ @NotNull
+ Object[] readMsgpackArray(@NotNull MessageUnpacker unpacker) throws IOException;
+
+ /**
+ * Serializes the given Object array as a MessagePack array using the provided packer.
+ *
+ * @param objects the Object array to serialize
+ * @param packer the MessagePacker to write to
+ * @throws IOException if an I/O error occurs during packing
+ */
+ void writeMsgpackArray(@NotNull Object[] objects, @NotNull MessagePacker packer) throws IOException;
+
+ /**
+ * Reads a JSON array from the given {@link JsonArray} and deserializes it into an Object array.
+ *
+ * @param json the {@link JsonArray} representing the array to deserialize
+ * @return the deserialized Object array
+ */
+ @NotNull
+ Object[] readFromJsonArray(@NotNull JsonArray json);
+
+ /**
+ * Serializes the given Object array as a JSON array.
+ *
+ * @param objects the Object array to serialize
+ * @return the resulting JsonArray
+ */
+ @NotNull
+ JsonArray asJsonArray(@NotNull Object[] objects);
+
+ /**
+ * Returns the lazily-initialized, process-wide {@link ObjectSerializer} singleton, reflectively
+ * loaded from the LiveObjects plugin on the classpath. Returns {@code null} if the plugin is not
+ * present; the lookup is retried on subsequent calls until it succeeds.
+ *
+ * @return the shared {@link ObjectSerializer} instance, or {@code null} if the plugin is unavailable.
+ */
+ @Nullable
+ static ObjectSerializer tryGet() {
+ return Holder.getSerializer();
+ }
+
+ /**
+ * Holds the lazily-initialized {@link ObjectSerializer} singleton. Interfaces cannot declare
+ * mutable static fields, so the cache lives here while {@link #tryGet()} delegates to it.
+ */
+ final class Holder {
+ private static final String TAG = ObjectSerializer.Holder.class.getName();
+ private static final String IMPLEMENTATION_CLASS = "io.ably.lib.liveobjects.serialization.DefaultObjectsSerializer";
+ private static volatile ObjectSerializer objectsSerializer;
+
+ private Holder() {}
+
+ @Nullable
+ static ObjectSerializer getSerializer() {
+ if (objectsSerializer == null) {
+ synchronized (Holder.class) {
+ if (objectsSerializer == null) { // Double-Checked Locking (DCL)
+ try {
+ Class> serializerClass = Class.forName(IMPLEMENTATION_CLASS);
+ objectsSerializer = (ObjectSerializer) serializerClass.getDeclaredConstructor().newInstance();
+ } catch (ClassNotFoundException | InstantiationException | IllegalAccessException |
+ NoSuchMethodException |
+ InvocationTargetException e) {
+ Log.w(TAG, "Failed to init ObjectSerializer, LiveObjects plugin not included in the classpath", e);
+ return null;
+ }
+ }
+ }
+ }
+ return objectsSerializer;
+ }
+ }
+}
diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateChange.java
similarity index 75%
rename from lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java
rename to lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateChange.java
index 180645f3c..d6bb65385 100644
--- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java
+++ b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateChange.java
@@ -1,10 +1,10 @@
-package io.ably.lib.objects.state;
+package io.ably.lib.liveobjects.state;
-import io.ably.lib.objects.ObjectsSubscription;
+import io.ably.lib.liveobjects.Subscription;
import org.jetbrains.annotations.NonBlocking;
import org.jetbrains.annotations.NotNull;
-public interface ObjectsStateChange {
+public interface ObjectStateChange {
/**
* Subscribes to a specific Objects synchronization state event.
*
@@ -17,7 +17,7 @@ public interface ObjectsStateChange {
* @return a subscription object that can be used to unsubscribe from the event
*/
@NonBlocking
- ObjectsSubscription on(@NotNull ObjectsStateEvent event, @NotNull ObjectsStateChange.Listener listener);
+ Subscription on(@NotNull ObjectStateEvent event, @NotNull ObjectStateChange.Listener listener);
/**
* Unsubscribes the specified listener from all synchronization state events.
@@ -28,7 +28,7 @@ public interface ObjectsStateChange {
* @param listener the listener to unregister from all events
*/
@NonBlocking
- void off(@NotNull ObjectsStateChange.Listener listener);
+ void off(@NotNull ObjectStateChange.Listener listener);
/**
* Unsubscribes all listeners from all synchronization state events.
@@ -42,15 +42,15 @@ public interface ObjectsStateChange {
/**
* Interface for receiving notifications about Objects synchronization state changes.
*
- * Implement this interface and register it with an ObjectsStateEmitter to be notified
+ * Implement this interface and register it with an {@code ObjectStateEmitter} to be notified
* when synchronization state transitions occur.
*/
interface Listener {
/**
* Called when the synchronization state changes.
*
- * @param objectsStateEvent The new state event (SYNCING or SYNCED)
+ * @param objectStateEvent The new state event (SYNCING or SYNCED)
*/
- void onStateChanged(ObjectsStateEvent objectsStateEvent);
+ void onStateChanged(ObjectStateEvent objectStateEvent);
}
}
diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateEvent.java
similarity index 71%
rename from lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java
rename to lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateEvent.java
index 1aa27203a..053d26116 100644
--- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java
+++ b/lib/src/main/java/io/ably/lib/liveobjects/state/ObjectStateEvent.java
@@ -1,12 +1,12 @@
-package io.ably.lib.objects.state;
+package io.ably.lib.liveobjects.state;
/**
* Represents the synchronization state of Ably Objects.
*
* This enum is used to notify listeners about state changes in the synchronization process.
- * Clients can register an {@link ObjectsStateChange.Listener} to receive these events.
+ * Clients can register an {@link ObjectStateChange.Listener} to receive these events.
*/
-public enum ObjectsStateEvent {
+public enum ObjectStateEvent {
/**
* Indicates that synchronization between local and remote objects is in progress.
*/
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/value/LiveCounter.java b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveCounter.java
new file mode 100644
index 000000000..484c1be15
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveCounter.java
@@ -0,0 +1,72 @@
+package io.ably.lib.liveobjects.value;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.lang.reflect.InvocationTargetException;
+
+/**
+ * An immutable value type representing the intent to create a new
+ * {@code LiveCounter} object with a specific initial count. Passed to mutation
+ * methods (such as {@code LiveMapInstance#set} or {@code LiveMapPathObject#set},
+ * wrapped via {@link LiveMapValue#of(LiveCounter)}) to assign a new
+ * {@code LiveCounter} to the objects graph.
+ *
+ *
This type is a holder for the initial value only - it is not a live,
+ * subscribable view of channel state. The {@code COUNTER_CREATE} operation it
+ * gives rise to is published when the enclosing mutation is applied.
+ *
+ *
Instances are obtained via the static {@link #create(Number)} factory and
+ * are immutable after creation. The initial count is held internally by the
+ * implementation; it has no public accessor.
+ *
+ *
Spec: RTLCV1, RTLCV2, RTLCV3
+ */
+public abstract class LiveCounter {
+
+ private static final String IMPLEMENTATION_CLASS = "io.ably.lib.liveobjects.value.DefaultLiveCounter";
+
+ /**
+ * Extended by the LiveObjects implementation; not intended for
+ * application subclassing. Avoids implicit empty public constructor.
+ */
+ protected LiveCounter() {
+ }
+
+ /**
+ * Creates a new {@code LiveCounter} value type with an initial count of 0.
+ *
+ *
Spec: RTLCV3, RTLCV3a1, RTLCV3b
+ *
+ * @return an immutable {@code LiveCounter} value type
+ * @throws IllegalStateException if the LiveObjects plugin is not on the classpath
+ */
+ @NotNull
+ public static LiveCounter create() {
+ return create(0);
+ }
+
+ /**
+ * Creates a new {@code LiveCounter} value type with the given initial count.
+ * No input validation is performed at creation time; validation is deferred
+ * to when the value is evaluated by a mutation method.
+ *
+ *
Spec: RTLCV3, RTLCV3b, RTLCV3c, RTLCV3d
+ *
+ * @param initialCount the initial count for the new {@code LiveCounter} object
+ * @return an immutable {@code LiveCounter} value type
+ * @throws IllegalStateException if the LiveObjects plugin is not on the classpath
+ */
+ @NotNull
+ public static LiveCounter create(@NotNull Number initialCount) {
+ try {
+ Class> implementation = Class.forName(IMPLEMENTATION_CLASS);
+ return (LiveCounter) implementation
+ .getDeclaredConstructor(Number.class)
+ .newInstance(initialCount);
+ } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException |
+ InvocationTargetException e) {
+ throw new IllegalStateException(
+ "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e);
+ }
+ }
+}
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMap.java b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMap.java
new file mode 100644
index 000000000..022622d8b
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMap.java
@@ -0,0 +1,75 @@
+package io.ably.lib.liveobjects.value;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * An immutable value type representing the intent to create a new
+ * {@code LiveMap} object with specific initial entries. Passed to mutation
+ * methods (such as {@code LiveMapInstance#set} or {@code LiveMapPathObject#set},
+ * wrapped via {@link LiveMapValue#of(LiveMap)}) to assign a new {@code LiveMap}
+ * to the objects graph. Entries may themselves contain nested {@code LiveMap} /
+ * {@code LiveCounter} value types, enabling composable object structures.
+ *
+ *
This type is a holder for the initial value only - it is not a live,
+ * subscribable view of channel state. The {@code MAP_CREATE} operation it gives
+ * rise to is published when the enclosing mutation is applied.
+ *
+ *
Instances are obtained via the static {@link #create(Map)} factory and
+ * are immutable after creation. The initial entries are held internally by the
+ * implementation; they have no public accessor.
+ *
+ *
Spec: RTLMV1, RTLMV2, RTLMV3
+ */
+public abstract class LiveMap {
+
+ private static final String IMPLEMENTATION_CLASS = "io.ably.lib.liveobjects.value.DefaultLiveMap";
+
+ /**
+ * Extended by the LiveObjects implementation; not intended for
+ * application subclassing. Avoids implicit empty public constructor.
+ */
+ protected LiveMap() {
+ }
+
+ /**
+ * Creates a new {@code LiveMap} value type with no initial entries.
+ *
+ *
Spec: RTLMV3, RTLMV3a1, RTLMV3b
+ *
+ * @return an immutable {@code LiveMap} value type
+ * @throws IllegalStateException if the LiveObjects plugin is not on the classpath
+ */
+ @NotNull
+ public static LiveMap create() {
+ return create(Collections.emptyMap());
+ }
+
+ /**
+ * Creates a new {@code LiveMap} value type with the given initial entries.
+ * No input validation is performed at creation time; validation is deferred
+ * to when the value is evaluated by a mutation method.
+ *
+ * Spec: RTLMV3, RTLMV3b, RTLMV3c, RTLMV3d
+ *
+ * @param entries the initial entries for the new {@code LiveMap} object
+ * @return an immutable {@code LiveMap} value type
+ * @throws IllegalStateException if the LiveObjects plugin is not on the classpath
+ */
+ @NotNull
+ public static LiveMap create(@NotNull Map entries) {
+ try {
+ Class> implementation = Class.forName(IMPLEMENTATION_CLASS);
+ return (LiveMap) implementation
+ .getDeclaredConstructor(Map.class)
+ .newInstance(entries);
+ } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException |
+ InvocationTargetException e) {
+ throw new IllegalStateException(
+ "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e);
+ }
+ }
+}
diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMapValue.java
similarity index 86%
rename from lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java
rename to lib/src/main/java/io/ably/lib/liveobjects/value/LiveMapValue.java
index ccba80330..406e48d02 100644
--- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java
+++ b/lib/src/main/java/io/ably/lib/liveobjects/value/LiveMapValue.java
@@ -1,14 +1,20 @@
-package io.ably.lib.objects.type.map;
+package io.ably.lib.liveobjects.value;
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
+ * The union of values assignable to a {@code LiveMap} key:
+ * {@code Boolean | Binary | Number | String | JsonArray | JsonObject |
+ * LiveCounter | LiveMap}. Provides compile-time type safety for write
+ * operations; the design follows Gson's {@code JsonElement} pattern.
+ *
+ * The {@link LiveMap} and {@link LiveCounter} variants hold new-object
+ * value types describing the initial state of a nested object to create -
+ * not references to existing live objects.
+ *
+ *
Spec: RTPO15a2 / RTINS12a2 / RTLM20 (accepted value types)
*/
public abstract class LiveMapValue {
@@ -20,10 +26,6 @@ public abstract class LiveMapValue {
@NotNull
public abstract Object getValue();
- /**
- * Type checking methods with default implementations
- */
-
/**
* Returns true if this LiveMapValue represents a Boolean value.
*
@@ -67,23 +69,21 @@ public abstract class LiveMapValue {
public boolean isJsonObject() { return false; }
/**
- * Returns true if this LiveMapValue represents a LiveCounter value.
+ * Returns true if this LiveMapValue represents a new {@link LiveCounter}
+ * value type.
*
* @return true if this is a LiveCounter value
*/
public boolean isLiveCounter() { return false; }
/**
- * Returns true if this LiveMapValue represents a LiveMap value.
+ * Returns true if this LiveMapValue represents a new {@link LiveMap}
+ * value type.
*
* @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.
*
@@ -150,9 +150,9 @@ public JsonObject getAsJsonObject() {
}
/**
- * Gets the LiveCounter value if this LiveMapValue represents a LiveCounter.
+ * Gets the {@link LiveCounter} value type if this LiveMapValue represents one.
*
- * @return the LiveCounter value
+ * @return the LiveCounter value type
* @throws IllegalStateException if this is not a LiveCounter value
*/
@NotNull
@@ -161,9 +161,9 @@ public LiveCounter getAsLiveCounter() {
}
/**
- * Gets the LiveMap value if this LiveMapValue represents a LiveMap.
+ * Gets the {@link LiveMap} value type if this LiveMapValue represents one.
*
- * @return the LiveMap value
+ * @return the LiveMap value type
* @throws IllegalStateException if this is not a LiveMap value
*/
@NotNull
@@ -171,10 +171,6 @@ public LiveMap getAsLiveMap() {
throw new IllegalStateException("Not a LiveMap value");
}
- /**
- * Static factory methods similar to JsonElement constructors
- */
-
/**
* Creates a LiveMapValue from a Boolean.
*
@@ -187,7 +183,8 @@ public static LiveMapValue of(@NotNull Boolean value) {
}
/**
- * Creates a LiveMapValue from a Binary.
+ * Creates a LiveMapValue from a Binary. The array is copied, so later
+ * modifications to {@code value} do not affect the created LiveMapValue.
*
* @param value the binary value
* @return a LiveMapValue containing the binary
@@ -242,9 +239,9 @@ public static LiveMapValue of(@NotNull JsonObject value) {
}
/**
- * Creates a LiveMapValue from a LiveCounter.
+ * Creates a LiveMapValue from a new {@link LiveCounter} value type.
*
- * @param value the LiveCounter value
+ * @param value the LiveCounter value type
* @return a LiveMapValue containing the LiveCounter
*/
@NotNull
@@ -253,9 +250,9 @@ public static LiveMapValue of(@NotNull LiveCounter value) {
}
/**
- * Creates a LiveMapValue from a LiveMap.
+ * Creates a LiveMapValue from a new {@link LiveMap} value type.
*
- * @param value the LiveMap value
+ * @param value the LiveMap value type
* @return a LiveMapValue containing the LiveMap
*/
@NotNull
@@ -294,19 +291,19 @@ private static final class BinaryValue extends LiveMapValue {
private final byte[] value;
BinaryValue(byte @NotNull [] value) {
- this.value = value;
+ this.value = value.clone();
}
@Override
public @NotNull Object getValue() {
- return value;
+ return value.clone();
}
@Override
public boolean isBinary() { return true; }
@Override
- public byte @NotNull [] getAsBinary() { return value; }
+ public byte @NotNull [] getAsBinary() { return value.clone(); }
}
/**
diff --git a/lib/src/main/java/io/ably/lib/liveobjects/value/package-info.java b/lib/src/main/java/io/ably/lib/liveobjects/value/package-info.java
new file mode 100644
index 000000000..6a4e798ec
--- /dev/null
+++ b/lib/src/main/java/io/ably/lib/liveobjects/value/package-info.java
@@ -0,0 +1,16 @@
+/**
+ * Write-side value types for LiveObjects mutations.
+ * {@link io.ably.lib.liveobjects.value.LiveMapValue} is the union of values
+ * assignable to a {@code LiveMap} key;
+ * {@link io.ably.lib.liveobjects.value.LiveMap} and
+ * {@link io.ably.lib.liveobjects.value.LiveCounter} are immutable initial-value
+ * holders describing new objects to be created by a mutation; they expose only
+ * the static {@code create} factories (RTLMV3 / RTLCV3), which delegate to the
+ * LiveObjects implementation extending these abstract classes. Their internal
+ * state ({@code entries} / {@code count}) is held by the implementation and
+ * has no public accessor.
+ *
+ *
Spec: RTLM20 / RTPO15a2 / RTINS12a2 (value union); RTLMV3 / RTLCV3
+ * (new-object value types)
+ */
+package io.ably.lib.liveobjects.value;
diff --git a/lib/src/main/java/io/ably/lib/objects/Adapter.java b/lib/src/main/java/io/ably/lib/objects/Adapter.java
deleted file mode 100644
index 76c35cc37..000000000
--- a/lib/src/main/java/io/ably/lib/objects/Adapter.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package io.ably.lib.objects;
-
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.realtime.ChannelBase;
-import io.ably.lib.realtime.Connection;
-import io.ably.lib.types.AblyException;
-import io.ably.lib.types.ClientOptions;
-import io.ably.lib.types.ErrorInfo;
-import io.ably.lib.util.Log;
-import org.jetbrains.annotations.NotNull;
-
-public class Adapter implements ObjectsAdapter {
- private final AblyRealtime ably;
- private static final String TAG = ObjectsAdapter.class.getName();
-
- public Adapter(@NotNull AblyRealtime ably) {
- this.ably = ably;
- }
-
- @Override
- public @NotNull ClientOptions getClientOptions() {
- return ably.options;
- }
-
- @Override
- public @NotNull Connection getConnection() {
- return ably.connection;
- }
-
- @Override
- public long getTime() throws AblyException {
- return ably.time();
- }
-
- @Override
- public @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException {
- if (ably.channels.containsKey(channelName)) {
- return ably.channels.get(channelName);
- } else {
- Log.e(TAG, "attachChannel(): channel not found: " + channelName);
- ErrorInfo errorInfo = new ErrorInfo("Channel not found: " + channelName, 404);
- throw AblyException.fromErrorInfo(errorInfo);
- }
- }
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java
deleted file mode 100644
index 1f34cafdd..000000000
--- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java
+++ /dev/null
@@ -1,60 +0,0 @@
-package io.ably.lib.objects;
-
-import io.ably.lib.realtime.ChannelState;
-import io.ably.lib.types.ProtocolMessage;
-import org.jetbrains.annotations.NotNull;
-
-/**
- * The LiveObjectsPlugin interface provides a mechanism for managing and interacting with
- * live data objects in a real-time environment. It allows for the retrieval, disposal, and
- * management of Objects instances associated with specific channel names.
- */
-public interface LiveObjectsPlugin {
-
- /**
- * Retrieves an instance of RealtimeObjects associated with the specified channel name.
- * This method ensures that a RealtimeObjects instance is available for the given channel,
- * creating one if it does not already exist.
- *
- * @param channelName the name of the channel for which the RealtimeObjects instance is to be retrieved.
- * @return the RealtimeObjects instance associated with the specified channel name.
- */
- @NotNull
- RealtimeObjects getInstance(@NotNull String channelName);
-
- /**
- * Handles a protocol message.
- * This method is invoked whenever a protocol message is received, allowing the implementation
- * to process the message and take appropriate actions.
- *
- * @param message the protocol message to handle.
- */
- void handle(@NotNull ProtocolMessage message);
-
- /**
- * Handles state changes for a specific channel.
- * This method is invoked whenever a channel's state changes, allowing the implementation
- * to update the RealtimeObjects instances accordingly based on the new state and presence of objects.
- *
- * @param channelName the name of the channel whose state has changed.
- * @param state the new state of the channel.
- * @param hasObjects flag indicates whether the channel has any associated objects.
- */
- void handleStateChange(@NotNull String channelName, @NotNull ChannelState state, boolean hasObjects);
-
- /**
- * Disposes of the RealtimeObjects instance associated with the specified channel name.
- * This method removes the RealtimeObjects instance for the given channel, releasing any
- * resources associated with it.
- * This is invoked when ablyRealtimeClient.channels.release(channelName) is called
- *
- * @param channelName the name of the channel whose RealtimeObjects instance is to be removed.
- */
- void dispose(@NotNull String channelName);
-
- /**
- * Disposes of the plugin instance and all underlying resources.
- * This is invoked when ablyRealtimeClient.close() is called
- */
- void dispose();
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java b/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java
deleted file mode 100644
index 0afd5ef2f..000000000
--- a/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package io.ably.lib.objects;
-
-import io.ably.lib.types.AblyException;
-
-/**
- * Callback interface for handling results of asynchronous Objects operations.
- * Used for operations like creating LiveMaps/LiveCounters, modifying entries, and retrieving objects.
- * Callbacks are executed on background threads managed by the Objects system.
- *
- * @param the type of the result returned by the asynchronous operation
- */
-public interface ObjectsCallback {
-
- /**
- * Called when the asynchronous operation completes successfully.
- * For modification operations (set, remove, increment), result is typically Void.
- * For creation/retrieval operations, result contains the created/retrieved object.
- *
- * @param result the result of the operation, may be null for modification operations
- */
- void onSuccess(T result);
-
- /**
- * Called when the asynchronous operation fails.
- * The exception contains detailed error information including error codes and messages.
- * Common errors include network issues, authentication failures, and validation errors.
- *
- * @param exception the exception that occurred during the operation
- */
- void onError(AblyException exception);
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java b/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java
deleted file mode 100644
index 81e7f3c08..000000000
--- a/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package io.ably.lib.objects;
-
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.util.Log;
-import org.jetbrains.annotations.Nullable;
-
-import java.lang.reflect.InvocationTargetException;
-
-public class ObjectsHelper {
-
- private static final String TAG = ObjectsHelper.class.getName();
- private static volatile ObjectsSerializer objectsSerializer;
-
- @Nullable
- public static LiveObjectsPlugin tryInitializeObjectsPlugin(AblyRealtime ablyRealtime) {
- try {
- Class> objectsImplementation = Class.forName("io.ably.lib.objects.DefaultLiveObjectsPlugin");
- ObjectsAdapter adapter = new Adapter(ablyRealtime);
- return (LiveObjectsPlugin) objectsImplementation
- .getDeclaredConstructor(ObjectsAdapter.class)
- .newInstance(adapter);
- } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException |
- InvocationTargetException e) {
- Log.i(TAG, "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e);
- return null;
- }
- }
-
- @Nullable
- public static ObjectsSerializer getSerializer() {
- if (objectsSerializer == null) {
- synchronized (ObjectsHelper.class) {
- if (objectsSerializer == null) { // Double-Checked Locking (DCL)
- try {
- Class> serializerClass = Class.forName("io.ably.lib.objects.serialization.DefaultObjectsSerializer");
- objectsSerializer = (ObjectsSerializer) serializerClass.getDeclaredConstructor().newInstance();
- } catch (ClassNotFoundException | InstantiationException | IllegalAccessException |
- NoSuchMethodException |
- InvocationTargetException e) {
- Log.w(TAG, "Failed to init ObjectsSerializer, LiveObjects plugin not included in the classpath", e);
- return null;
- }
- }
- }
- }
- return objectsSerializer;
- }
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java
deleted file mode 100644
index 9bee9a8fd..000000000
--- a/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package io.ably.lib.objects;
-
-import com.google.gson.JsonArray;
-import org.jetbrains.annotations.NotNull;
-import org.msgpack.core.MessagePacker;
-import org.msgpack.core.MessageUnpacker;
-
-import java.io.IOException;
-
-/**
- * Serializer interface for converting between objects and their MessagePack or JSON representations.
- */
-public interface ObjectsSerializer {
- /**
- * Reads a MessagePack array from the given unpacker and deserializes it into an Object array.
- *
- * @param unpacker the MessageUnpacker to read from
- * @return the deserialized Object array
- * @throws IOException if an I/O error occurs during unpacking
- */
- @NotNull
- Object[] readMsgpackArray(@NotNull MessageUnpacker unpacker) throws IOException;
-
- /**
- * Serializes the given Object array as a MessagePack array using the provided packer.
- *
- * @param objects the Object array to serialize
- * @param packer the MessagePacker to write to
- * @throws IOException if an I/O error occurs during packing
- */
- void writeMsgpackArray(@NotNull Object[] objects, @NotNull MessagePacker packer) throws IOException;
-
- /**
- * Reads a JSON array from the given {@link JsonArray} and deserializes it into an Object array.
- *
- * @param json the {@link JsonArray} representing the array to deserialize
- * @return the deserialized Object array
- */
- @NotNull
- Object[] readFromJsonArray(@NotNull JsonArray json);
-
- /**
- * Serializes the given Object array as a JSON array.
- *
- * @param objects the Object array to serialize
- * @return the resulting JsonArray
- */
- @NotNull
- JsonArray asJsonArray(@NotNull Object[] objects);
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java
deleted file mode 100644
index 2b22d71d4..000000000
--- a/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package io.ably.lib.objects;
-
-/**
- * Represents a objects subscription that can be unsubscribed from.
- * This interface provides a way to clean up and remove subscriptions when they are no longer needed.
- * Example usage:
- *
- * {@code
- * ObjectsSubscription s = objects.subscribe(ObjectsStateEvent.SYNCING, new ObjectsStateListener() {});
- * // Later when done with the subscription
- * s.unsubscribe();
- * }
- *
- * Spec: RTLO4b5
- */
-public interface ObjectsSubscription {
- /**
- * This method should be called when the subscription is no longer needed,
- * it will make sure no further events will be sent to the subscriber and
- * that references to the subscriber are cleaned up.
- * Spec: RTLO4b5a
- */
- void unsubscribe();
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java b/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java
deleted file mode 100644
index 6e111b304..000000000
--- a/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java
+++ /dev/null
@@ -1,166 +0,0 @@
-package io.ably.lib.objects;
-
-import io.ably.lib.objects.state.ObjectsStateChange;
-import io.ably.lib.objects.type.counter.LiveCounter;
-import io.ably.lib.objects.type.map.LiveMap;
-import io.ably.lib.objects.type.map.LiveMapValue;
-import org.jetbrains.annotations.Blocking;
-import org.jetbrains.annotations.NonBlocking;
-import org.jetbrains.annotations.NotNull;
-
-import java.util.Map;
-
-/**
- * The RealtimeObjects interface provides methods to interact with live data objects,
- * such as maps and counters, in a real-time environment. It supports both synchronous
- * and asynchronous operations for retrieving and creating objects.
- *
- * Implementations of this interface must be thread-safe as they may be accessed
- * from multiple threads concurrently.
- */
-public interface RealtimeObjects extends ObjectsStateChange {
-
- /**
- * Retrieves the root LiveMap object.
- * When called without a type variable, we return a default root type which is based on globally defined interface for Objects feature.
- * A user can provide an explicit type for the getRoot method to explicitly set the type structure on this particular channel.
- * This is useful when working with multiple channels with different underlying data structure.
- *
- * @return the root LiveMap instance.
- */
- @Blocking
- @NotNull
- LiveMap getRoot();
-
- /**
- * 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
- * and returns it.
- *
- * @return the newly created empty LiveMap instance.
- */
- @Blocking
- @NotNull
- LiveMap createMap();
-
- /**
- * 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.
- *
- * 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(realtimeObjects.createCounter()),
- * "nested", LiveMapValue.of(realtimeObjects.createMap())
- * );
- * LiveMap map = realtimeObjects.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 Map entries);
-
- /**
- * 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 COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally
- * using the provided data and returns it.
- *
- * @return the newly created LiveCounter instance with initial value of 0.
- */
- @Blocking
- @NotNull
- LiveCounter createCounter();
-
- /**
- * Creates a new LiveCounter with an initial value.
- * 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 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 initialValue the initial value of the LiveCounter.
- * @return the newly created LiveCounter instance.
- */
- @Blocking
- @NotNull
- LiveCounter createCounter(@NotNull Number initialValue);
-
- /**
- * Asynchronously retrieves the root LiveMap object.
- * When called without a type variable, we return a default root type which is based on globally defined interface for Objects feature.
- * A user can provide an explicit type for the getRoot method to explicitly set the type structure on this particular channel.
- * This is useful when working with multiple channels with different underlying data structure.
- *
- * @param callback the callback to handle the result or error.
- */
- @NonBlocking
- void getRootAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback);
-
- /**
- * 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
- * and returns it.
- *
- * @param callback the callback to handle the result or error.
- */
- @NonBlocking
- void createMapAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback);
-
- /**
- * 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 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 Map entries, @NotNull ObjectsCallback<@NotNull LiveMap> callback);
-
- /**
- * 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 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 callback the callback to handle the result or error.
- */
- @NonBlocking
- void createCounterAsync(@NotNull ObjectsCallback<@NotNull LiveCounter> callback);
-
- /**
- * Asynchronously creates a new LiveCounter with an initial value.
- * 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 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 initialValue the initial value of the LiveCounter.
- * @param callback the callback to handle the result or error.
- */
- @NonBlocking
- void createCounterAsync(@NotNull Number initialValue, @NotNull ObjectsCallback<@NotNull LiveCounter> callback);
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleChange.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleChange.java
deleted file mode 100644
index c8d0f5745..000000000
--- a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleChange.java
+++ /dev/null
@@ -1,69 +0,0 @@
-package io.ably.lib.objects.type;
-
-import io.ably.lib.objects.ObjectsSubscription;
-import org.jetbrains.annotations.NonBlocking;
-import org.jetbrains.annotations.NotNull;
-
-/**
- * Interface for managing subscriptions to Object lifecycle events.
- *
- * This interface provides methods to subscribe to and manage notifications about significant lifecycle
- * changes that occur to Object, such as deletion. More events can be added in the future.
- * Multiple listeners can be registered independently, and each can be managed separately.
- *
- * Lifecycle events are different from data update events - they represent changes
- * to the object's existence state rather than changes to the object's data content.
- *
- * @see ObjectLifecycleEvent for the available lifecycle events
- */
-public interface ObjectLifecycleChange {
- /**
- * Subscribes to a specific Object lifecycle event.
- *
- *
This method registers the provided listener to be notified when the specified
- * lifecycle event occurs. The returned subscription can be used to
- * unsubscribe later when the notifications are no longer needed.
- *
- * @param event the lifecycle event to subscribe to
- * @param listener the listener that will be called when the event occurs
- * @return a subscription object that can be used to unsubscribe from the event
- */
- @NonBlocking
- ObjectsSubscription on(@NotNull ObjectLifecycleEvent event, @NotNull ObjectLifecycleChange.Listener listener);
-
- /**
- * Unsubscribes the specified listener from all lifecycle events.
- *
- *
After calling this method, the provided listener will no longer receive
- * any lifecycle event notifications.
- *
- * @param listener the listener to unregister from all events
- */
- @NonBlocking
- void off(@NotNull ObjectLifecycleChange.Listener listener);
-
- /**
- * Unsubscribes all listeners from all lifecycle events.
- *
- *
After calling this method, no listeners will receive any lifecycle
- * event notifications until new listeners are registered.
- */
- @NonBlocking
- void offAll();
-
- /**
- * Interface for receiving notifications about Object lifecycle changes.
- *
- * Implement this interface and register it with an ObjectLifecycleChange provider
- * to be notified when lifecycle events occur, such as object creation or deletion.
- */
- @FunctionalInterface
- interface Listener {
- /**
- * Called when a lifecycle event occurs.
- *
- * @param lifecycleEvent The lifecycle event that occurred
- */
- void onLifecycleEvent(@NotNull ObjectLifecycleEvent lifecycleEvent);
- }
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleEvent.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleEvent.java
deleted file mode 100644
index 7a2d1aa7d..000000000
--- a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleEvent.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package io.ably.lib.objects.type;
-
-/**
- * Represents lifecycle events for an Ably Object.
- *
- * This enum notifies listeners about significant lifecycle changes that occur to an Object during its lifetime.
- * Clients can register a {@link ObjectLifecycleChange.Listener} to receive these events.
- */
-public enum ObjectLifecycleEvent {
- /**
- * Indicates that an Object has been deleted (tombstoned).
- * Emitted once when the object is tombstoned server-side (i.e., deleted and no longer addressable).
- * Not re-emitted during client-side garbage collection of tombstones.
- */
- DELETED
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java
deleted file mode 100644
index 8ee1e1578..000000000
--- a/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java
+++ /dev/null
@@ -1,27 +0,0 @@
-package io.ably.lib.objects.type;
-
-import org.jetbrains.annotations.Nullable;
-
-/**
- * Abstract base class for all LiveMap/LiveCounter update notifications.
- * Provides common structure for updates that occur on LiveMap and LiveCounter objects.
- * Contains the update data that describes what changed in the object.
- * Spec: RTLO4b4
- */
-public abstract class ObjectUpdate {
- /**
- * The update data containing details about the change that occurred
- * Spec: RTLO4b4a
- */
- @Nullable
- protected final Object update;
-
- /**
- * Creates a ObjectUpdate with the specified update data.
- *
- * @param update the data describing the change, or null for no-op updates
- */
- protected ObjectUpdate(@Nullable Object update) {
- this.update = update;
- }
-}
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
deleted file mode 100644
index 958cf05b1..000000000
--- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package io.ably.lib.objects.type.counter;
-
-import io.ably.lib.objects.ObjectsCallback;
-import io.ably.lib.objects.type.ObjectLifecycleChange;
-import org.jetbrains.annotations.Blocking;
-import org.jetbrains.annotations.NonBlocking;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Contract;
-
-/**
- * The LiveCounter interface provides methods to interact with a live counter.
- * It allows incrementing, decrementing, and retrieving the current value of the counter,
- * both synchronously and asynchronously.
- */
-public interface LiveCounter extends LiveCounterChange, ObjectLifecycleChange {
-
- /**
- * 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 decrement(@NotNull Number amount);
-
- /**
- * 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 Number amount, @NotNull ObjectsCallback callback);
-
- /**
- * Decrements the value of the counter by the specified amount asynchronously.
- * An alias for calling {@link LiveCounter#incrementAsync(Number, ObjectsCallback)} with a negative amount.
- * Spec: RTLC13
- *
- * @param amount the amount by which to decrement the counter
- * @param callback the callback to be invoked upon completion of the operation.
- */
- @NonBlocking
- void decrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback);
-
- /**
- * Retrieves the current value of the counter.
- *
- * @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.
- Double value();
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java
deleted file mode 100644
index 79f842e74..000000000
--- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package io.ably.lib.objects.type.counter;
-
-import io.ably.lib.objects.ObjectsSubscription;
-import org.jetbrains.annotations.NonBlocking;
-import org.jetbrains.annotations.NotNull;
-
-/**
- * Provides methods to subscribe to real-time updates on LiveCounter objects.
- * Enables clients to receive notifications when counter values change due to
- * operations performed by any client connected to the same channel.
- */
-public interface LiveCounterChange {
-
- /**
- * Subscribes to real-time updates on this LiveCounter object.
- * Multiple listeners can be subscribed to the same object independently.
- * Spec: RTLO4b
- *
- * @param listener the listener to be notified of counter updates
- * @return an ObjectsSubscription for managing this specific listener
- */
- @NonBlocking
- @NotNull ObjectsSubscription subscribe(@NotNull Listener listener);
-
- /**
- * Unsubscribes a specific listener from receiving updates.
- * Has no effect if the listener is not currently subscribed.
- * Spec: RTLO4c
- *
- * @param listener the listener to be unsubscribed
- */
- @NonBlocking
- void unsubscribe(@NotNull Listener listener);
-
- /**
- * Unsubscribes all listeners from receiving updates.
- * No notifications will be delivered until new listeners are subscribed.
- * Spec: RTLO4d
- */
- @NonBlocking
- void unsubscribeAll();
-
- /**
- * Listener interface for receiving LiveCounter updates.
- * Spec: RTLO4b3
- */
- interface Listener {
- /**
- * Called when the LiveCounter has been updated.
- * Should execute quickly as it's called from the real-time processing thread.
- *
- * @param update details about the counter change
- */
- void onUpdated(@NotNull LiveCounterUpdate update);
- }
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java
deleted file mode 100644
index d7921a0b5..000000000
--- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java
+++ /dev/null
@@ -1,80 +0,0 @@
-package io.ably.lib.objects.type.counter;
-
-import io.ably.lib.objects.type.ObjectUpdate;
-import org.jetbrains.annotations.NotNull;
-
-/**
- * Represents an update that occurred on a LiveCounter object.
- * Contains information about counter value changes from increment/decrement operations.
- * Updates can represent positive changes (increments) or negative changes (decrements).
- *
- * @spec RTLC11, RTLC11a - LiveCounter update structure and behavior
- */
-public class LiveCounterUpdate extends ObjectUpdate {
-
- /**
- * Creates a no-op LiveCounterUpdate representing no actual change.
- */
- public LiveCounterUpdate() {
- super(null);
- }
-
- /**
- * Creates a LiveCounterUpdate with the specified amount change.
- *
- * @param amount the amount by which the counter changed (positive = increment, negative = decrement)
- */
- public LiveCounterUpdate(@NotNull Double amount) {
- super(new Update(amount));
- }
-
- /**
- * Gets the update information containing the amount of change.
- *
- * @return the Update object with the counter modification amount
- */
- @NotNull
- public LiveCounterUpdate.Update getUpdate() {
- return (Update) update;
- }
-
- /**
- * Returns a string representation of this LiveCounterUpdate.
- *
- * @return a string showing the amount of change to the counter
- */
- @Override
- public String toString() {
- if (update == null) {
- return "LiveCounterUpdate{no change}";
- }
- return "LiveCounterUpdate{amount=" + getUpdate().getAmount() + "}";
- }
-
- /**
- * Contains the specific details of a counter update operation.
- *
- * @spec RTLC11b, RTLC11b1 - Counter update data structure
- */
- public static class Update {
- private final @NotNull Double amount;
-
- /**
- * Creates an Update with the specified amount.
- *
- * @param amount the counter change amount (positive = increment, negative = decrement)
- */
- public Update(@NotNull Double amount) {
- this.amount = amount;
- }
-
- /**
- * Gets the amount by which the counter value was modified.
- *
- * @return the change amount (positive for increments, negative for decrements)
- */
- public @NotNull Double getAmount() {
- return amount;
- }
- }
-}
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
deleted file mode 100644
index f180fe168..000000000
--- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java
+++ /dev/null
@@ -1,131 +0,0 @@
-package io.ably.lib.objects.type.map;
-
-import io.ably.lib.objects.ObjectsCallback;
-import io.ably.lib.objects.type.ObjectLifecycleChange;
-import org.jetbrains.annotations.Blocking;
-import org.jetbrains.annotations.NonBlocking;
-import org.jetbrains.annotations.Contract;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-import org.jetbrains.annotations.Unmodifiable;
-
-import java.util.Map;
-
-/**
- * The LiveMap interface provides methods to interact with a live, real-time map structure.
- * It supports both synchronous and asynchronous operations for managing key-value pairs.
- */
-public interface LiveMap extends LiveMapChange, ObjectLifecycleChange {
-
- /**
- * Retrieves the value associated with the specified key.
- * If this map object is tombstoned (deleted), null is returned.
- * If no entry is associated with the specified key, null is returned.
- * If map entry is tombstoned (deleted), null is returned.
- * If the value associated with the provided key is an objectId string of another RealtimeObject, a reference to
- * that RealtimeObject is returned, provided it exists in the local pool and is not tombstoned. Otherwise, null is returned.
- * If the value is not an objectId, then that value is returned.
- * Spec: RTLM5, RTLM5a
- *
- * @param keyName the key whose associated value is to be returned.
- * @return the value associated with the specified key, or null if the key does not exist.
- */
- @Nullable
- LiveMapValue get(@NotNull String keyName);
-
- /**
- * Retrieves all entries (key-value pairs) in the map.
- * Spec: RTLM11, RTLM11a
- *
- * @return an iterable collection of all entries in the map.
- */
- @NotNull
- @Unmodifiable
- Iterable> entries();
-
- /**
- * Retrieves all keys in the map.
- * Spec: RTLM12, RTLM12a
- *
- * @return an iterable collection of all keys in the map.
- */
- @NotNull
- @Unmodifiable
- Iterable keys();
-
- /**
- * Retrieves all values in the map.
- * Spec: RTLM13, RTLM13a
- *
- * @return an iterable collection of all values in the map.
- */
- @NotNull
- @Unmodifiable
- Iterable values();
-
- /**
- * Sets the specified key to the given value in the map.
- * Send a MAP_SET operation to the realtime system to set a key on this LiveMap object to a specified value.
- * 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 LiveMapValue value);
-
- /**
- * Removes the specified key and its associated value from the map.
- * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object.
- * 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.
- */
- @Blocking
- void remove(@NotNull String keyName);
-
- /**
- * Retrieves the number of entries in the map.
- * Spec: RTLM10, RTLM10a
- *
- * @return the size of the map.
- */
- @Contract(pure = true) // Indicates this method does not modify the state of the object.
- @NotNull
- Long size();
-
- /**
- * Asynchronously sets the specified key to the given value in the map.
- * Send a MAP_SET operation to the realtime system to set a key on this LiveMap object to a specified value.
- * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when
- * the published MAP_SET operation is echoed back to the client and applied to the object following the regular
- * operation application procedure.
- * Spec: RTLM20
- *
- * @param keyName the key to be set.
- * @param value the value to be associated with the key.
- * @param callback the callback to handle the result or any errors.
- */
- @NonBlocking
- void setAsync(@NotNull String keyName, @NotNull LiveMapValue value, @NotNull ObjectsCallback callback);
-
- /**
- * Asynchronously removes the specified key and its associated value from the map.
- * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object.
- * 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.
- */
- @NonBlocking
- void removeAsync(@NotNull String keyName, @NotNull ObjectsCallback callback);
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java
deleted file mode 100644
index c30ae7850..000000000
--- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package io.ably.lib.objects.type.map;
-
-import io.ably.lib.objects.ObjectsSubscription;
-import org.jetbrains.annotations.NonBlocking;
-import org.jetbrains.annotations.NotNull;
-
-/**
- * Provides methods to subscribe to real-time updates on LiveMap objects.
- * Enables clients to receive notifications when map entries are added, updated, or removed.
- * Uses last-write-wins conflict resolution when multiple clients modify the same key.
- */
-public interface LiveMapChange {
-
- /**
- * Subscribes to real-time updates on this LiveMap object.
- * Multiple listeners can be subscribed to the same object independently.
- * Spec: RTLO4b
- *
- * @param listener the listener to be notified of map updates
- * @return an ObjectsSubscription for managing this specific listener
- */
- @NonBlocking
- @NotNull ObjectsSubscription subscribe(@NotNull Listener listener);
-
- /**
- * Unsubscribes a specific listener from receiving updates.
- * Has no effect if the listener is not currently subscribed.
- * Spec: RTLO4c
- *
- * @param listener the listener to be unsubscribed
- */
- @NonBlocking
- void unsubscribe(@NotNull Listener listener);
-
- /**
- * Unsubscribes all listeners from receiving updates.
- * No notifications will be delivered until new listeners are subscribed.
- * Spec: RTLO4d
- */
- @NonBlocking
- void unsubscribeAll();
-
- /**
- * Listener interface for receiving LiveMap updates.
- * Spec: RTLO4b3
- */
- interface Listener {
- /**
- * Called when the LiveMap has been updated.
- * Should execute quickly as it's called from the real-time processing thread.
- *
- * @param update details about which keys were modified and how
- */
- void onUpdated(@NotNull LiveMapUpdate update);
- }
-}
diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java
deleted file mode 100644
index 08fe2fc39..000000000
--- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java
+++ /dev/null
@@ -1,66 +0,0 @@
-package io.ably.lib.objects.type.map;
-
-import io.ably.lib.objects.type.ObjectUpdate;
-import org.jetbrains.annotations.NotNull;
-
-import java.util.Map;
-
-/**
- * Represents an update that occurred on a LiveMap object.
- * Contains information about which keys were modified and whether they were updated or removed.
- *
- * @spec RTLM18, RTLM18a - LiveMap update structure and behavior
- */
-public class LiveMapUpdate extends ObjectUpdate {
-
- /**
- * Creates a no-op LiveMapUpdate representing no actual change.
- */
- public LiveMapUpdate() {
- super(null);
- }
-
- /**
- * Creates a LiveMapUpdate with the specified key changes.
- *
- * @param update map of key names to their change types (UPDATED or REMOVED)
- */
- public LiveMapUpdate(@NotNull Map update) {
- super(update);
- }
-
- /**
- * Gets the map of key changes that occurred in this update.
- *
- * @return map of key names to their change types
- */
- @NotNull
- public Map getUpdate() {
- return (Map) update;
- }
-
- /**
- * Returns a string representation of this LiveMapUpdate.
- *
- * @return a string showing the map key changes in this update
- */
- @Override
- public String toString() {
- if (update == null) {
- return "LiveMapUpdate{no change}";
- }
- return "LiveMapUpdate{changes=" + getUpdate() + "}";
- }
-
- /**
- * Indicates the type of change that occurred to a map key.
- *
- * @spec RTLM18b - Map change types
- */
- public enum Change {
- /** The key was added or its value was modified */
- UPDATED,
- /** The key was removed from the map */
- REMOVED
- }
-}
diff --git a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java
index d9053c8d2..b991ed63b 100644
--- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java
+++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java
@@ -5,8 +5,7 @@
import java.util.List;
import java.util.Map;
-import io.ably.lib.objects.ObjectsHelper;
-import io.ably.lib.objects.LiveObjectsPlugin;
+import io.ably.lib.liveobjects.LiveObjectsPlugin;
import io.ably.lib.rest.AblyRest;
import io.ably.lib.rest.Auth;
import io.ably.lib.transport.ConnectionManager;
@@ -74,7 +73,7 @@ public AblyRealtime(ClientOptions options) throws AblyException {
final InternalChannels channels = new InternalChannels();
this.channels = channels;
- liveObjectsPlugin = ObjectsHelper.tryInitializeObjectsPlugin(this);
+ liveObjectsPlugin = LiveObjectsPlugin.tryInitialize(this);
connection = new Connection(this, channels, platformAgentProvider, liveObjectsPlugin);
diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java
index a778e3391..fe68a481d 100644
--- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java
+++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java
@@ -13,8 +13,8 @@
import io.ably.lib.http.Http;
import io.ably.lib.http.HttpCore;
import io.ably.lib.http.HttpUtils;
-import io.ably.lib.objects.RealtimeObjects;
-import io.ably.lib.objects.LiveObjectsPlugin;
+import io.ably.lib.liveobjects.RealtimeObject;
+import io.ably.lib.liveobjects.LiveObjectsPlugin;
import io.ably.lib.rest.MessageEditsMixin;
import io.ably.lib.rest.RestAnnotations;
import io.ably.lib.transport.ConnectionManager;
@@ -112,15 +112,7 @@ public abstract class ChannelBase extends EventEmitter') to your dependency tree", 400, 40019)
- );
- }
- return liveObjectsPlugin.getInstance(name);
- }
+ public RealtimeObject object;
public final RealtimeAnnotations annotations;
@@ -1695,7 +1687,9 @@ else if(stateChange.current.equals(failureState)) {
this.decodingContext = new DecodingContext();
this.liveObjectsPlugin = liveObjectsPlugin;
if (liveObjectsPlugin != null) {
- liveObjectsPlugin.getInstance(name); // Make objects instance ready to process sync messages
+ this.object = liveObjectsPlugin.getInstance(name);
+ } else {
+ this.object = RealtimeObject.Unavailable.INSTANCE;
}
this.annotations = new RealtimeAnnotations(
this,
diff --git a/lib/src/main/java/io/ably/lib/realtime/Connection.java b/lib/src/main/java/io/ably/lib/realtime/Connection.java
index 3ba28a434..dde9238d0 100644
--- a/lib/src/main/java/io/ably/lib/realtime/Connection.java
+++ b/lib/src/main/java/io/ably/lib/realtime/Connection.java
@@ -1,6 +1,6 @@
package io.ably.lib.realtime;
-import io.ably.lib.objects.LiveObjectsPlugin;
+import io.ably.lib.liveobjects.LiveObjectsPlugin;
import io.ably.lib.realtime.ConnectionStateListener.ConnectionStateChange;
import io.ably.lib.transport.ConnectionManager;
import io.ably.lib.types.AblyException;
diff --git a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java
index c9985ef61..15f3b31ab 100644
--- a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java
+++ b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java
@@ -14,7 +14,7 @@
import io.ably.lib.debug.DebugOptions;
import io.ably.lib.debug.DebugOptions.RawProtocolListener;
import io.ably.lib.http.HttpHelpers;
-import io.ably.lib.objects.LiveObjectsPlugin;
+import io.ably.lib.liveobjects.LiveObjectsPlugin;
import io.ably.lib.realtime.AblyRealtime;
import io.ably.lib.realtime.Channel;
import io.ably.lib.realtime.ChannelState;
diff --git a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java
index e813a21b7..bab089c6e 100644
--- a/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java
+++ b/lib/src/main/java/io/ably/lib/types/ProtocolMessage.java
@@ -5,9 +5,8 @@
import java.util.Map;
import com.google.gson.annotations.JsonAdapter;
-import io.ably.lib.objects.ObjectsSerializer;
-import io.ably.lib.objects.ObjectsHelper;
-import io.ably.lib.objects.ObjectsJsonSerializer;
+import io.ably.lib.liveobjects.serialization.ObjectJsonSerializer;
+import io.ably.lib.liveobjects.serialization.ObjectSerializer;
import org.jetbrains.annotations.Nullable;
import org.msgpack.core.MessageFormat;
import org.msgpack.core.MessagePacker;
@@ -134,7 +133,7 @@ public ProtocolMessage(Action action, String channel) {
* This is targeted and specific to the state field, so won't affect other fields
*/
@Nullable
- @JsonAdapter(ObjectsJsonSerializer.class)
+ @JsonAdapter(ObjectJsonSerializer.class)
public Object[] state;
public @Nullable PublishResult[] res;
@@ -162,7 +161,7 @@ void writeMsgpack(MessagePacker packer) throws IOException {
if(params != null) ++fieldCount;
if(channelSerial != null) ++fieldCount;
if(annotations != null) ++fieldCount;
- if(state != null && ObjectsHelper.getSerializer() != null) ++fieldCount;
+ if(state != null && ObjectSerializer.tryGet() != null) ++fieldCount;
if(res != null) ++fieldCount;
packer.packMapHeader(fieldCount);
packer.packString("action");
@@ -204,7 +203,7 @@ void writeMsgpack(MessagePacker packer) throws IOException {
AnnotationSerializer.writeMsgpackArray(annotations, packer);
}
if(state != null) {
- ObjectsSerializer objectsSerializer = ObjectsHelper.getSerializer();
+ ObjectSerializer objectsSerializer = ObjectSerializer.tryGet();
if (objectsSerializer != null) {
packer.packString("state");
objectsSerializer.writeMsgpackArray(state, packer);
@@ -279,7 +278,7 @@ ProtocolMessage readMsgpack(MessageUnpacker unpacker) throws IOException {
annotations = AnnotationSerializer.readMsgpackArray(unpacker);
break;
case "state":
- ObjectsSerializer objectsSerializer = ObjectsHelper.getSerializer();
+ ObjectSerializer objectsSerializer = ObjectSerializer.tryGet();
if (objectsSerializer != null) {
state = objectsSerializer.readMsgpackArray(unpacker);
} else {
diff --git a/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java b/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java
index c110c9af3..9ccd005a4 100644
--- a/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java
+++ b/lib/src/main/java/io/ably/lib/types/RecoveryKeyContext.java
@@ -2,8 +2,8 @@
import com.google.gson.JsonSyntaxException;
-import java.util.HashMap;
import java.util.Map;
+import java.util.TreeMap;
import io.ably.lib.util.Log;
import io.ably.lib.util.Serialisation;
@@ -13,7 +13,8 @@ public class RecoveryKeyContext {
private final String connectionKey;
private final long msgSerial;
- private final Map channelSerials = new HashMap<>();
+ // Sorted so encode() produces deterministic, key-ordered JSON regardless of input map ordering.
+ private final Map channelSerials = new TreeMap<>();
public RecoveryKeyContext(String connectionKey, long msgSerial, Map channelSerials) {
this.connectionKey = connectionKey;
diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java
index 5ff53c6f4..8fa2a3bbc 100644
--- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java
+++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeChannelTest.java
@@ -2644,11 +2644,14 @@ public void channel_get_objects_throws_exception() throws AblyException {
new ChannelWaiter(channel).waitFor(ChannelState.attached);
assertEquals("Verify attached state reached", channel.state, ChannelState.attached);
- AblyException exception = assertThrows(AblyException.class, channel::getObjects);
+ IllegalStateException exception = assertThrows(IllegalStateException.class, () -> channel.object.get());
assertNotNull(exception);
- assertEquals(40019, exception.errorInfo.code);
- assertEquals(400, exception.errorInfo.statusCode);
- assertTrue(exception.errorInfo.message.contains("LiveObjects plugin hasn't been installed"));
+ assertTrue(exception.getMessage().contains("LiveObjects plugin hasn't been installed"));
+
+ AblyException cause = (AblyException) exception.getCause();
+ assertNotNull(cause);
+ assertEquals(40019, cause.errorInfo.code);
+ assertEquals(400, cause.errorInfo.statusCode);
}
}
diff --git a/liveobjects/build.gradle.kts b/liveobjects/build.gradle.kts
index 5b45ce92e..9d6ad9420 100644
--- a/liveobjects/build.gradle.kts
+++ b/liveobjects/build.gradle.kts
@@ -30,17 +30,17 @@ tasks.withType().configureEach {
outputs.upToDateWhen { false }
}
-tasks.register("runLiveObjectUnitTests") {
+tasks.register("runLiveObjectsUnitTests") {
filter {
- includeTestsMatching("io.ably.lib.objects.unit.*")
+ includeTestsMatching("io.ably.lib.liveobjects.unit.*")
}
}
-tasks.register("runLiveObjectIntegrationTests") {
+tasks.register("runLiveObjectsIntegrationTests") {
filter {
- includeTestsMatching("io.ably.lib.objects.integration.*")
+ includeTestsMatching("io.ably.lib.liveobjects.integration.*")
// Exclude the base integration test class
- excludeTestsMatching("io.ably.lib.objects.integration.setup.IntegrationTest")
+ excludeTestsMatching("io.ably.lib.liveobjects.integration.setup.IntegrationTest")
}
}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt
new file mode 100644
index 000000000..ff6dbb070
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt
@@ -0,0 +1,41 @@
+package io.ably.lib.liveobjects
+
+import io.ably.lib.liveobjects.adapter.AblyClientAdapter
+import io.ably.lib.liveobjects.path.types.LiveMapPathObject
+import io.ably.lib.liveobjects.state.ObjectStateChange
+import io.ably.lib.liveobjects.state.ObjectStateEvent
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Default implementation of [RealtimeObject], the entry point to the strongly-typed,
+ * path-based LiveObjects API for a single channel.
+ *
+ * This is currently a skeleton: the path-based read and subscribe operations are not yet
+ * implemented. The method bodies will be filled in as the path-based API is built out.
+ *
+ * Spec: RTO23
+ */
+internal class DefaultRealtimeObject(
+ internal val channelName: String,
+ internal val adapter: AblyClientAdapter,
+) : RealtimeObject {
+
+ override fun get(): CompletableFuture = TODO("Not yet implemented")
+
+ override fun on(event: ObjectStateEvent, listener: ObjectStateChange.Listener): Subscription {
+ // TODO - subscribe logic goes here
+ return onceSubscription {
+ // TODO - remove ObjectStateChange.Listener
+ }
+ }
+
+ override fun off(listener: ObjectStateChange.Listener): Unit = TODO("Not yet implemented")
+
+ override fun offAll(): Unit = TODO("Not yet implemented")
+
+ /** Validates the channel is configured for access (read/subscribe) operations. Spec: RTO25 */
+ internal fun throwIfInvalidAccessApiConfiguration() = adapter.throwIfInvalidAccessApiConfiguration(channelName)
+
+ /** Validates the channel is configured for write (mutation) operations. Spec: RTO26 */
+ internal fun throwIfInvalidWriteApiConfiguration() = adapter.throwIfInvalidWriteApiConfiguration(channelName)
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Errors.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Errors.kt
new file mode 100644
index 000000000..98bd89691
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Errors.kt
@@ -0,0 +1,55 @@
+package io.ably.lib.liveobjects
+
+import io.ably.lib.types.AblyException
+import io.ably.lib.types.ErrorInfo
+
+/**
+ * Error codes and helpers for the path-based public API implementation.
+ * Copied (and extended with the path-API codes) from the legacy package so
+ * this package has no dependency on `io.ably.lib.objects`.
+ */
+internal enum class ObjectErrorCode(val code: Int) {
+ BadRequest(40_000),
+ InternalError(50_000),
+ MaxMessageSizeExceeded(40_009),
+ InvalidObject(92_000),
+ InvalidInputParams(40_003),
+ MapValueDataTypeUnsupported(40_013),
+ PathNotResolved(92_005), // RTPO3c2 - write operation on a path that does not resolve
+ ObjectsTypeMismatch(92_007), // RTTS5d2/RTTS9d2 - operation on a cast wrapper with mismatched resolved type
+ // Channel mode and state validation error codes
+ ChannelModeRequired(40_024),
+ ChannelStateError(90_001),
+ PublishAndApplyFailedDueToChannelState(92_008),
+}
+
+internal enum class ObjectHttpStatusCode(val code: Int) {
+ BadRequest(400),
+ InternalServerError(500),
+}
+
+internal fun objectException(
+ errorMessage: String,
+ errorCode: ObjectErrorCode,
+ statusCode: ObjectHttpStatusCode = ObjectHttpStatusCode.BadRequest,
+ cause: Throwable? = null,
+): AblyException {
+ val errorInfo = ErrorInfo(errorMessage, statusCode.code, errorCode.code)
+ return cause?.let { AblyException.fromErrorInfo(it, errorInfo) } ?: AblyException.fromErrorInfo(errorInfo)
+}
+
+/** ErrorInfo 400 / 40003 - invalid input (RTLMV4a/b, RTLCV4a, key validation). */
+internal fun invalidInputError(message: String) =
+ objectException(message, ObjectErrorCode.InvalidInputParams)
+
+/** ErrorInfo 400 / 92005 - write operation on an unresolvable path (RTPO3c2). */
+internal fun pathNotResolvedError(path: String) =
+ objectException("Path could not be resolved: \"$path\"", ObjectErrorCode.PathNotResolved)
+
+/** ErrorInfo 400 / 92007 - resolved/wrapped type does not match the typed wrapper (RTTS5d2/RTTS9d2). */
+internal fun typeMismatchError(message: String) =
+ objectException(message, ObjectErrorCode.ObjectsTypeMismatch)
+
+/** ErrorInfo 500 / 92000 - invalid internal object state. */
+internal fun objectStateError(message: String) =
+ objectException(message, ObjectErrorCode.InvalidObject, ObjectHttpStatusCode.InternalServerError)
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Helpers.kt
new file mode 100644
index 000000000..e8859cc2b
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Helpers.kt
@@ -0,0 +1,197 @@
+package io.ably.lib.liveobjects
+
+import io.ably.lib.liveobjects.adapter.AblyClientAdapter
+import io.ably.lib.liveobjects.message.WireObjectMessage
+import io.ably.lib.liveobjects.message.size
+import io.ably.lib.realtime.ChannelState
+import io.ably.lib.realtime.CompletionListener
+import io.ably.lib.realtime.ConnectionEvent
+import io.ably.lib.realtime.ConnectionStateListener
+import io.ably.lib.types.*
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+/**
+ * Wraps [onUnsubscribe] in a [Subscription] that runs the cleanup at most once; further
+ * calls are no-ops. Use it wherever a [Subscription] is returned: `EventEmitter.off` is
+ * `synchronized`, so this avoids re-acquiring that lock (and re-running teardown) on
+ * repeated unsubscribe calls. Thread-safe.
+ *
+ * Spec: SUB2a, SUB2b
+ */
+internal fun onceSubscription(onUnsubscribe: () -> Unit): Subscription {
+ val unsubscribed = AtomicBoolean(false)
+ return Subscription {
+ if (unsubscribed.compareAndSet(false, true)) {
+ onUnsubscribe()
+ }
+ }
+}
+
+/**
+ * Validates that the channel is configured for the access (read/subscribe) API: it must be
+ * attachable (not detached/failed) and have the `object_subscribe` mode. Copied from the
+ * legacy `io.ably.lib.objects` helpers so this package has no dependency on that package.
+ *
+ * Spec: RTO25
+ */
+internal fun AblyClientAdapter.throwIfInvalidAccessApiConfiguration(channelName: String) {
+ throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed))
+ throwIfMissingChannelMode(channelName, ChannelMode.object_subscribe)
+}
+
+/**
+ * Validates that the channel is configured for the write (mutation) API: message echo must be
+ * enabled, the channel must be usable (not detached/failed/suspended) and have the
+ * `object_publish` mode.
+ *
+ * Spec: RTO26
+ */
+internal fun AblyClientAdapter.throwIfInvalidWriteApiConfiguration(channelName: String) {
+ throwIfEchoMessagesDisabled()
+ throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed, ChannelState.suspended))
+ throwIfMissingChannelMode(channelName, ChannelMode.object_publish)
+}
+
+/**
+ * Resolves the effective channel modes: the attached `modes` if present, otherwise the
+ * user-provided channel options as a best effort.
+ *
+ * Spec: RTO2a, RTO2b
+ */
+internal fun AblyClientAdapter.getChannelModes(channelName: String): Array? {
+ val channel = getChannel(channelName)
+ channel.modes?.let { modes -> if (modes.isNotEmpty()) return modes } // RTO2a
+ channel.options?.let { options -> if (options.hasModes()) return options.modes } // RTO2b
+ return null
+}
+
+// Spec: RTO2a2, RTO2b2
+private fun AblyClientAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) {
+ val channelModes = getChannelModes(channelName)
+ if (channelModes == null || !channelModes.contains(channelMode)) {
+ throw objectException(
+ "\"${channelMode.name}\" channel mode must be set for this operation",
+ ObjectErrorCode.ChannelModeRequired,
+ )
+ }
+}
+
+private fun AblyClientAdapter.throwIfInChannelState(channelName: String, channelStates: Array) {
+ val currentState = getChannel(channelName).state
+ if (currentState == null || channelStates.contains(currentState)) {
+ throw objectException("Channel is in invalid state: $currentState", ObjectErrorCode.ChannelStateError)
+ }
+}
+
+private fun AblyClientAdapter.throwIfEchoMessagesDisabled() {
+ if (!clientOptions.echoMessages) {
+ throw objectException(
+ "\"echoMessages\" client option must be enabled for this operation",
+ ObjectErrorCode.BadRequest,
+ )
+ }
+}
+
+internal fun AblyClientAdapter.throwIfUnpublishableState(channelName: String) {
+ if (!connectionManager.isActive) {
+ throw ablyException(connectionManager.stateErrorInfo)
+ }
+ throwIfInChannelState(channelName, arrayOf(ChannelState.failed, ChannelState.suspended))
+}
+
+internal val AblyClientAdapter.connectionManager get() = connection.connectionManager
+
+internal fun AblyClientAdapter.onGCGracePeriodUpdated(block : (Long?) -> Unit) : Subscription {
+ connectionManager.objectsGCGracePeriod?.let { block(it) }
+ // Return new objectsGCGracePeriod whenever connection state changes to connected
+ val listener: (_: ConnectionStateListener.ConnectionStateChange) -> Unit = {
+ block(connectionManager.objectsGCGracePeriod)
+ }
+ connection.on(ConnectionEvent.connected, listener)
+ return onceSubscription { connection.off(listener) }
+}
+
+/**
+ * Spec: RTO15g
+ */
+internal suspend fun AblyClientAdapter.sendAsync(message: ProtocolMessage): PublishResult = suspendCancellableCoroutine { continuation ->
+ try {
+ connectionManager.send(message, clientOptions.queueMessages, object : Callback {
+ override fun onSuccess(result: PublishResult) {
+ continuation.resume(result)
+ }
+
+ override fun onError(reason: ErrorInfo) {
+ continuation.resumeWithException(ablyException(reason))
+ }
+ })
+ } catch (e: Exception) {
+ continuation.resumeWithException(e)
+ }
+}
+
+internal suspend fun AblyClientAdapter.attachAsync(channelName: String) = suspendCancellableCoroutine { continuation ->
+ try {
+ getChannel(channelName).attach(object : CompletionListener {
+ override fun onSuccess() {
+ continuation.resume(Unit)
+ }
+
+ override fun onError(reason: ErrorInfo) {
+ continuation.resumeWithException(ablyException(reason))
+ }
+ })
+ } catch (e: Exception) {
+ continuation.resumeWithException(e)
+ }
+}
+
+/**
+ * Spec: RTO15d
+ */
+internal fun AblyClientAdapter.ensureMessageSizeWithinLimit(wireObjectMessages: Array) {
+ val maximumAllowedSize = connectionManager.maxMessageSize
+ val objectsTotalMessageSize = wireObjectMessages.sumOf { it.size() }
+ if (objectsTotalMessageSize > maximumAllowedSize) {
+ throw ablyException("ObjectMessages size $objectsTotalMessageSize exceeds maximum allowed size of $maximumAllowedSize bytes",
+ ObjectErrorCode.MaxMessageSizeExceeded)
+ }
+}
+
+internal fun AblyClientAdapter.setChannelSerial(channelName: String, protocolMessage: ProtocolMessage) {
+ if (protocolMessage.action != ProtocolMessage.Action.`object`) return
+ val channelSerial = protocolMessage.channelSerial
+ if (channelSerial.isNullOrEmpty()) return
+ getChannel(channelName).properties.channelSerial = channelSerial
+}
+
+internal suspend fun AblyClientAdapter.ensureAttached(channelName: String) {
+ val channel = getChannel(channelName)
+ when (val currentChannelStatus = channel.state) {
+ ChannelState.initialized -> attachAsync(channelName)
+ ChannelState.attached -> return
+ ChannelState.attaching -> {
+ val attachDeferred = CompletableDeferred()
+ getChannel(channelName).once {
+ when(it.current) {
+ ChannelState.attached -> attachDeferred.complete(Unit)
+ else -> {
+ val exception = ablyException("Channel $channelName is in invalid state: ${it.current}, " +
+ "error: ${it.reason}", ObjectErrorCode.ChannelStateError)
+ attachDeferred.completeExceptionally(exception)
+ }
+ }
+ }
+ if (channel.state == ChannelState.attached) {
+ attachDeferred.complete(Unit)
+ }
+ attachDeferred.await()
+ }
+ else ->
+ throw ablyException("Channel $channelName is in invalid state: $currentChannelStatus", ObjectErrorCode.ChannelStateError)
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Utils.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Utils.kt
new file mode 100644
index 000000000..8cc628a32
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/Utils.kt
@@ -0,0 +1,60 @@
+package io.ably.lib.liveobjects
+
+import io.ably.lib.types.AblyException
+import io.ably.lib.types.ErrorInfo
+import java.nio.charset.StandardCharsets
+
+internal fun ablyException(
+ errorMessage: String,
+ errorCode: ObjectErrorCode,
+ statusCode: ObjectHttpStatusCode = ObjectHttpStatusCode.BadRequest,
+ cause: Throwable? = null,
+): AblyException {
+ val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode)
+ return createAblyException(errorInfo, cause)
+}
+
+internal fun ablyException(
+ errorInfo: ErrorInfo,
+ cause: Throwable? = null,
+): AblyException = createAblyException(errorInfo, cause)
+
+private fun createErrorInfo(
+ errorMessage: String,
+ errorCode: ObjectErrorCode,
+ statusCode: ObjectHttpStatusCode,
+) = ErrorInfo(errorMessage, statusCode.code, errorCode.code)
+
+private fun createAblyException(
+ errorInfo: ErrorInfo,
+ cause: Throwable?,
+) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) }
+ ?: AblyException.fromErrorInfo(errorInfo)
+
+internal fun clientError(errorMessage: String) = ablyException(errorMessage, ObjectErrorCode.BadRequest, ObjectHttpStatusCode.BadRequest)
+
+internal fun serverError(errorMessage: String) = ablyException(errorMessage, ObjectErrorCode.InternalError, ObjectHttpStatusCode.InternalServerError)
+
+internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyException {
+ return ablyException(errorMessage, ObjectErrorCode.InvalidObject, ObjectHttpStatusCode.InternalServerError, cause)
+}
+
+internal fun invalidInputError(errorMessage: String, cause: Throwable? = null): AblyException {
+ return ablyException(errorMessage, ObjectErrorCode.InvalidInputParams, ObjectHttpStatusCode.InternalServerError, cause)
+}
+
+/**
+ * Calculates the byte size of a string.
+ * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference.
+ * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4.
+ */
+internal val String.byteSize: Int
+ get() = this.toByteArray(StandardCharsets.UTF_8).size
+
+/**
+ * Generates a random nonce string for object creation.
+ */
+internal fun generateNonce(): String {
+ val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // avoid calculation using range
+ return (1..16).map { chars.random() }.joinToString("")
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstance.kt
new file mode 100644
index 000000000..0b710bbca
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstance.kt
@@ -0,0 +1,46 @@
+package io.ably.lib.liveobjects.instance
+
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.instance.types.BinaryInstance
+import io.ably.lib.liveobjects.instance.types.BooleanInstance
+import io.ably.lib.liveobjects.instance.types.JsonArrayInstance
+import io.ably.lib.liveobjects.instance.types.JsonObjectInstance
+import io.ably.lib.liveobjects.instance.types.LiveCounterInstance
+import io.ably.lib.liveobjects.instance.types.LiveMapInstance
+import io.ably.lib.liveobjects.instance.types.NumberInstance
+import io.ably.lib.liveobjects.instance.types.StringInstance
+
+/**
+ * Default implementation of [Instance], the identity-addressed node in the LiveObjects graph.
+ *
+ * An instance is always bound to a specific resolved value of a known type, so this base is
+ * abstract: each concrete sub-type supplies [getType] and [compactJson] (left abstract here)
+ * and overrides only the single `as*` cast matching its own type to return `this`. The
+ * remaining `as*` casts fall through to the implementations here, which fail fast because the
+ * wrapped value is not of the requested type.
+ *
+ * Only the channel's [channelObject] context is carried; unlike a path object there is no
+ * parent/child path, since an instance is identity-addressed.
+ *
+ * Spec: RTINS1, RTTS7
+ */
+internal abstract class DefaultInstance(
+ internal val channelObject: DefaultRealtimeObject,
+) : Instance {
+
+ override fun asLiveMap(): LiveMapInstance = throw IllegalStateException("Not a LiveMap instance")
+
+ override fun asLiveCounter(): LiveCounterInstance = throw IllegalStateException("Not a LiveCounter instance")
+
+ override fun asNumber(): NumberInstance = throw IllegalStateException("Not a Number instance")
+
+ override fun asString(): StringInstance = throw IllegalStateException("Not a String instance")
+
+ override fun asBoolean(): BooleanInstance = throw IllegalStateException("Not a Boolean instance")
+
+ override fun asBinary(): BinaryInstance = throw IllegalStateException("Not a Binary instance")
+
+ override fun asJsonObject(): JsonObjectInstance = throw IllegalStateException("Not a JsonObject instance")
+
+ override fun asJsonArray(): JsonArrayInstance = throw IllegalStateException("Not a JsonArray instance")
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstanceSubscriptionEvent.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstanceSubscriptionEvent.kt
new file mode 100644
index 000000000..428a3b88f
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/DefaultInstanceSubscriptionEvent.kt
@@ -0,0 +1,20 @@
+package io.ably.lib.liveobjects.instance
+
+import io.ably.lib.liveobjects.message.ObjectMessage
+
+/**
+ * Default implementation of [InstanceSubscriptionEvent], the event delivered to an
+ * [InstanceListener] when the wrapped LiveObject is updated. A plain holder for the updated
+ * [Instance] and the source [ObjectMessage] (if any).
+ *
+ * Spec: RTINS16e
+ */
+internal class DefaultInstanceSubscriptionEvent(
+ private val instance: Instance,
+ private val message: ObjectMessage?,
+) : InstanceSubscriptionEvent {
+
+ override fun getObject(): Instance = instance
+
+ override fun getMessage(): ObjectMessage? = message
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBinaryInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBinaryInstance.kt
new file mode 100644
index 000000000..0abf41285
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBinaryInstance.kt
@@ -0,0 +1,31 @@
+package io.ably.lib.liveobjects.instance.types
+
+import com.google.gson.JsonPrimitive
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.instance.DefaultInstance
+
+/**
+ * Default implementation of [BinaryInstance], a read-only primitive view that only adds a
+ * type-narrowed, non-null [value]; left unimplemented for now.
+ *
+ * Spec: RTTS10c
+ */
+internal class DefaultBinaryInstance(
+ channelObject: DefaultRealtimeObject,
+) : DefaultInstance(channelObject), BinaryInstance {
+
+ override fun getType(): ValueType = ValueType.BINARY
+
+ override fun compactJson(): JsonPrimitive {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun asBinary(): BinaryInstance = this
+
+ override fun value(): ByteArray {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBooleanInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBooleanInstance.kt
new file mode 100644
index 000000000..ab5eeae4d
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultBooleanInstance.kt
@@ -0,0 +1,31 @@
+package io.ably.lib.liveobjects.instance.types
+
+import com.google.gson.JsonPrimitive
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.instance.DefaultInstance
+
+/**
+ * Default implementation of [BooleanInstance], a read-only primitive view that only adds a
+ * type-narrowed, non-null [value]; left unimplemented for now.
+ *
+ * Spec: RTTS10c
+ */
+internal class DefaultBooleanInstance(
+ channelObject: DefaultRealtimeObject,
+) : DefaultInstance(channelObject), BooleanInstance {
+
+ override fun getType(): ValueType = ValueType.BOOLEAN
+
+ override fun compactJson(): JsonPrimitive {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun asBoolean(): BooleanInstance = this
+
+ override fun value(): Boolean {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonArrayInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonArrayInstance.kt
new file mode 100644
index 000000000..ecd755a32
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonArrayInstance.kt
@@ -0,0 +1,31 @@
+package io.ably.lib.liveobjects.instance.types
+
+import com.google.gson.JsonArray
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.instance.DefaultInstance
+
+/**
+ * Default implementation of [JsonArrayInstance], a read-only primitive view that only adds
+ * a type-narrowed, non-null [value]; left unimplemented for now.
+ *
+ * Spec: RTTS10c
+ */
+internal class DefaultJsonArrayInstance(
+ channelObject: DefaultRealtimeObject,
+) : DefaultInstance(channelObject), JsonArrayInstance {
+
+ override fun getType(): ValueType = ValueType.JSON_ARRAY
+
+ override fun compactJson(): JsonArray {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun asJsonArray(): JsonArrayInstance = this
+
+ override fun value(): JsonArray {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonObjectInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonObjectInstance.kt
new file mode 100644
index 000000000..3ce012fd6
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultJsonObjectInstance.kt
@@ -0,0 +1,31 @@
+package io.ably.lib.liveobjects.instance.types
+
+import com.google.gson.JsonObject
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.instance.DefaultInstance
+
+/**
+ * Default implementation of [JsonObjectInstance], a read-only primitive view that only adds
+ * a type-narrowed, non-null [value]; left unimplemented for now.
+ *
+ * Spec: RTTS10c
+ */
+internal class DefaultJsonObjectInstance(
+ channelObject: DefaultRealtimeObject,
+) : DefaultInstance(channelObject), JsonObjectInstance {
+
+ override fun getType(): ValueType = ValueType.JSON_OBJECT
+
+ override fun compactJson(): JsonObject {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun asJsonObject(): JsonObjectInstance = this
+
+ override fun value(): JsonObject {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveCounterInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveCounterInstance.kt
new file mode 100644
index 000000000..60aae7cc4
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveCounterInstance.kt
@@ -0,0 +1,65 @@
+package io.ably.lib.liveobjects.instance.types
+
+import com.google.gson.JsonPrimitive
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.Subscription
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.instance.DefaultInstance
+import io.ably.lib.liveobjects.instance.InstanceListener
+import io.ably.lib.liveobjects.onceSubscription
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Default implementation of [LiveCounterInstance], adding counter operations and subscribe
+ * on top of [DefaultInstance]; all left unimplemented for now.
+ *
+ * Spec: RTTS10b
+ */
+internal class DefaultLiveCounterInstance(
+ channelObject: DefaultRealtimeObject,
+) : DefaultInstance(channelObject), LiveCounterInstance {
+
+ override fun getType(): ValueType = ValueType.LIVE_COUNTER
+
+ override fun compactJson(): JsonPrimitive {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun asLiveCounter(): LiveCounterInstance = this
+
+ override fun getId(): String = TODO("Not yet implemented")
+
+ override fun value(): Double {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun increment(): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun increment(amount: Number): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun decrement(): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun decrement(amount: Number): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun subscribe(listener: InstanceListener): Subscription {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ // TODO - subscribe logic goes here
+ return onceSubscription {
+ // TODO - remove InstanceListener
+ }
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveMapInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveMapInstance.kt
new file mode 100644
index 000000000..22d9c3f10
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveMapInstance.kt
@@ -0,0 +1,78 @@
+package io.ably.lib.liveobjects.instance.types
+
+import com.google.gson.JsonObject
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.Subscription
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.instance.DefaultInstance
+import io.ably.lib.liveobjects.instance.Instance
+import io.ably.lib.liveobjects.instance.InstanceListener
+import io.ably.lib.liveobjects.onceSubscription
+import io.ably.lib.liveobjects.value.LiveMapValue
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Default implementation of [LiveMapInstance], adding map reads, writes and subscribe on top
+ * of [DefaultInstance]; all left unimplemented for now.
+ *
+ * Spec: RTTS10a
+ */
+internal class DefaultLiveMapInstance(
+ channelObject: DefaultRealtimeObject,
+) : DefaultInstance(channelObject), LiveMapInstance {
+
+ override fun getType(): ValueType = ValueType.LIVE_MAP
+
+ override fun compactJson(): JsonObject {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun asLiveMap(): LiveMapInstance = this
+
+ override fun getId(): String = TODO("Not yet implemented")
+
+ @Suppress("RedundantNullableReturnType")
+ override fun get(key: String): Instance? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun entries(): Iterable> {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun keys(): Iterable {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun values(): Iterable {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun size(): Long {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun set(key: String, value: LiveMapValue): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun remove(key: String): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun subscribe(listener: InstanceListener): Subscription {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ // TODO - subscribe logic goes here
+ return onceSubscription {
+ // TODO - remove InstanceListener
+ }
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultNumberInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultNumberInstance.kt
new file mode 100644
index 000000000..27910de7b
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultNumberInstance.kt
@@ -0,0 +1,31 @@
+package io.ably.lib.liveobjects.instance.types
+
+import com.google.gson.JsonPrimitive
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.instance.DefaultInstance
+
+/**
+ * Default implementation of [NumberInstance], a read-only primitive view that only adds a
+ * type-narrowed, non-null [value]; left unimplemented for now.
+ *
+ * Spec: RTTS10c
+ */
+internal class DefaultNumberInstance(
+ channelObject: DefaultRealtimeObject,
+) : DefaultInstance(channelObject), NumberInstance {
+
+ override fun getType(): ValueType = ValueType.NUMBER
+
+ override fun compactJson(): JsonPrimitive {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun asNumber(): NumberInstance = this
+
+ override fun value(): Number {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultStringInstance.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultStringInstance.kt
new file mode 100644
index 000000000..998608e9b
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultStringInstance.kt
@@ -0,0 +1,31 @@
+package io.ably.lib.liveobjects.instance.types
+
+import com.google.gson.JsonPrimitive
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.instance.DefaultInstance
+
+/**
+ * Default implementation of [StringInstance], a read-only primitive view that only adds a
+ * type-narrowed, non-null [value]; left unimplemented for now.
+ *
+ * Spec: RTTS10c
+ */
+internal class DefaultStringInstance(
+ channelObject: DefaultRealtimeObject,
+) : DefaultInstance(channelObject), StringInstance {
+
+ override fun getType(): ValueType = ValueType.STRING
+
+ override fun compactJson(): JsonPrimitive {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+
+ override fun asString(): StringInstance = this
+
+ override fun value(): String {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt
new file mode 100644
index 000000000..d206b37fe
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/DefaultObjectMessage.kt
@@ -0,0 +1,146 @@
+package io.ably.lib.liveobjects.message
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import io.ably.lib.liveobjects.objectStateError
+import java.util.*
+
+/**
+ * Builds the user-facing PublicAPI::ObjectMessage from an inbound wire
+ * ObjectMessage that carried an operation. Mirrors ably-js
+ * `objectmessage.ts#toUserFacingMessage`.
+ *
+ * Precondition (PAOM3a1): the source message has its `operation` populated.
+ *
+ * Spec: PAOM3
+ */
+internal fun WireObjectMessage.toPublicMessage(channelName: String): ObjectMessage =
+ DefaultObjectMessage(this, channelName)
+
+/**
+ * PublicAPI::ObjectMessage implementation - a read-only view over the source
+ * wire message. Spec: PAOM1, PAOM2
+ */
+internal class DefaultObjectMessage(
+ private val message: WireObjectMessage,
+ private val channelName: String,
+) : ObjectMessage {
+
+ private val operation: ObjectOperation = DefaultObjectOperation(
+ message.operation ?: throw objectStateError("Cannot build public ObjectMessage without an operation") // PAOM3a1
+ )
+
+ override fun getId(): String? = message.id // PAOM2a
+ override fun getClientId(): String? = message.clientId // PAOM2b
+ override fun getConnectionId(): String? = message.connectionId // PAOM2c
+ override fun getTimestamp(): Long? = message.timestamp // PAOM2d
+ override fun getChannel(): String = channelName // PAOM2e, PAOM3b
+ override fun getOperation(): ObjectOperation = operation // PAOM2f
+ override fun getSerial(): String? = message.serial // PAOM2g
+ override fun getSerialTimestamp(): Long? = message.serialTimestamp // PAOM2h
+ override fun getSiteCode(): String? = message.siteCode // PAOM2i
+ override fun getExtras(): JsonObject? = message.extras // PAOM2j
+}
+
+/**
+ * PublicAPI::ObjectOperation implementation. Resolves the outbound-only
+ * `*CreateWithObjectId` variants back to their derived MapCreate/CounterCreate
+ * forms. Spec: PAOOP1, PAOOP2, PAOOP3
+ */
+internal class DefaultObjectOperation(private val operation: WireObjectOperation) : ObjectOperation {
+
+ override fun getAction(): ObjectOperationAction = operation.action.toPublic() // PAOOP2a
+
+ override fun getObjectId(): String = operation.objectId // PAOOP2b
+
+ // PAOOP3b - prefer mapCreate, else the MapCreate the WithObjectId variant was derived from
+ override fun getMapCreate(): MapCreate? =
+ (operation.mapCreate ?: operation.mapCreateWithObjectId?.derivedFrom)?.let { DefaultMapCreate(it) }
+
+ override fun getMapSet(): MapSet? = operation.mapSet?.let { DefaultMapSet(it) } // PAOOP2d
+
+ override fun getMapRemove(): MapRemove? = operation.mapRemove?.let { DefaultMapRemove(it) } // PAOOP2e
+
+ // PAOOP3c - prefer counterCreate, else the derived CounterCreate
+ override fun getCounterCreate(): CounterCreate? =
+ (operation.counterCreate ?: operation.counterCreateWithObjectId?.derivedFrom)?.let { DefaultCounterCreate(it) }
+
+ override fun getCounterInc(): CounterInc? = operation.counterInc?.let { DefaultCounterInc(it) } // PAOOP2g
+
+ override fun getObjectDelete(): ObjectDelete? = operation.objectDelete?.let { DefaultObjectDelete } // PAOOP2h
+
+ override fun getMapClear(): MapClear? = operation.mapClear?.let { DefaultMapClear } // PAOOP2i
+}
+
+/** Spec: MCR2 */
+internal class DefaultMapCreate(private val mapCreate: WireMapCreate) : MapCreate {
+ override fun getSemantics(): ObjectsMapSemantics = mapCreate.semantics.toPublic()
+ override fun getEntries(): Map =
+ Collections.unmodifiableMap(mapCreate.entries.mapValues { (_, entry) -> DefaultObjectsMapEntry(entry) })
+}
+
+/** Spec: MST2 */
+internal class DefaultMapSet(private val mapSet: WireMapSet) : MapSet {
+ override fun getKey(): String = mapSet.key
+ override fun getValue(): ObjectData = DefaultObjectData(mapSet.value)
+}
+
+/** Spec: MRM2 */
+internal class DefaultMapRemove(private val mapRemove: WireMapRemove) : MapRemove {
+ override fun getKey(): String = mapRemove.key
+}
+
+/** Spec: CCR2 */
+internal class DefaultCounterCreate(private val counterCreate: WireCounterCreate) : CounterCreate {
+ override fun getCount(): Double = counterCreate.count
+}
+
+/** Spec: CIN2 */
+internal class DefaultCounterInc(private val counterInc: WireCounterInc) : CounterInc {
+ override fun getNumber(): Double = counterInc.number
+}
+
+/** Spec: ODE2 - no attributes */
+internal object DefaultObjectDelete : ObjectDelete
+
+/** Spec: MCL2 - no attributes */
+internal object DefaultMapClear : MapClear
+
+/** Spec: OME2 */
+internal class DefaultObjectsMapEntry(private val entry: WireObjectsMapEntry) : ObjectsMapEntry {
+ override fun getTombstone(): Boolean? = entry.tombstone
+ override fun getTimeserial(): String? = entry.timeserial
+ override fun getSerialTimestamp(): Long? = entry.serialTimestamp
+ override fun getData(): ObjectData? = entry.data?.let { DefaultObjectData(it) }
+}
+
+/**
+ * Decoded public ObjectData: binary is delivered decoded (the wire form is
+ * base64); there is no `encoding` field in the public shape. Spec: OD2
+ */
+internal class DefaultObjectData(private val data: WireObjectData) : ObjectData {
+ override fun getObjectId(): String? = data.objectId
+ override fun getString(): String? = data.string
+ override fun getNumber(): Double? = data.number
+ override fun getBoolean(): Boolean? = data.boolean
+ override fun getBytes(): ByteArray? = data.bytes?.let { Base64.getDecoder().decode(it) }
+ override fun getJson(): JsonElement? = data.json
+}
+
+/** Internal action -> public enum; unrecognized wire values map to UNKNOWN. Spec: PAOOP2a, OOP2 */
+internal fun WireObjectOperationAction.toPublic(): ObjectOperationAction = when (this) {
+ WireObjectOperationAction.MapCreate -> ObjectOperationAction.MAP_CREATE
+ WireObjectOperationAction.MapSet -> ObjectOperationAction.MAP_SET
+ WireObjectOperationAction.MapRemove -> ObjectOperationAction.MAP_REMOVE
+ WireObjectOperationAction.CounterCreate -> ObjectOperationAction.COUNTER_CREATE
+ WireObjectOperationAction.CounterInc -> ObjectOperationAction.COUNTER_INC
+ WireObjectOperationAction.ObjectDelete -> ObjectOperationAction.OBJECT_DELETE
+ WireObjectOperationAction.MapClear -> ObjectOperationAction.MAP_CLEAR
+ WireObjectOperationAction.Unknown -> ObjectOperationAction.UNKNOWN
+}
+
+/** Internal semantics -> public enum. Spec: OMP2 */
+internal fun WireObjectsMapSemantics.toPublic(): ObjectsMapSemantics = when (this) {
+ WireObjectsMapSemantics.LWW -> ObjectsMapSemantics.LWW
+ WireObjectsMapSemantics.Unknown -> ObjectsMapSemantics.UNKNOWN
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt
new file mode 100644
index 000000000..e29b73533
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/message/WireObjectMessage.kt
@@ -0,0 +1,290 @@
+package io.ably.lib.liveobjects.message
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.annotations.JsonAdapter
+import com.google.gson.annotations.SerializedName
+import io.ably.lib.liveobjects.byteSize
+import io.ably.lib.liveobjects.serialization.WireObjectDataJsonSerializer
+import io.ably.lib.liveobjects.serialization.gson
+import java.util.Base64
+
+/**
+ * Wire-level object model for the path-based public API implementation.
+ *
+ * Copied from the legacy internal model (`io.ably.lib.objects.ObjectMessage`)
+ * so that this package has no dependency on `io.ably.lib.objects`. The `Wire`
+ * prefix distinguishes these internal carriers from the public interfaces in
+ * `io.ably.lib.object.message`.
+ *
+ * Spec: OM*, OOP*, OD*, MCR*, MST*, MRM*, CCR*, CIN*, ODE*, MCL*, OME*, MCRO*, CCRO*, OMP*, OCN*, OST*
+ */
+
+/** Spec: OOP2 */
+internal enum class WireObjectOperationAction(val code: Int) {
+ MapCreate(0),
+ MapSet(1),
+ MapRemove(2),
+ CounterCreate(3),
+ CounterInc(4),
+ ObjectDelete(5),
+ MapClear(6),
+ Unknown(-1); // code for unknown value during deserialization
+}
+
+/** Spec: OMP2 */
+internal enum class WireObjectsMapSemantics(val code: Int) {
+ LWW(0),
+ Unknown(-1); // code for unknown value during deserialization
+}
+
+/** Spec: OD1, OD2 - binary carried as base64 string on the wire */
+@JsonAdapter(WireObjectDataJsonSerializer::class)
+internal data class WireObjectData(
+ val objectId: String? = null, // OD2a
+ val string: String? = null, // OD2f
+ val number: Double? = null, // OD2e
+ val boolean: Boolean? = null, // OD2c
+ val bytes: String? = null, // OD2d - base64
+ val json: JsonElement? = null, // decoded JSON leaf
+)
+
+/** Spec: MCR2 */
+internal data class WireMapCreate(
+ val semantics: WireObjectsMapSemantics, // MCR2a
+ val entries: Map, // MCR2b
+)
+
+/** Spec: MST2 */
+internal data class WireMapSet(
+ val key: String, // MST2a
+ val value: WireObjectData, // MST2b
+)
+
+/** Spec: MRM2 */
+internal data class WireMapRemove(
+ val key: String, // MRM2a
+)
+
+/** Spec: CCR2 */
+internal data class WireCounterCreate(
+ val count: Double, // CCR2a
+)
+
+/** Spec: CIN2 */
+internal data class WireCounterInc(
+ val number: Double, // CIN2a
+)
+
+/** Spec: ODE2 - no attributes */
+internal object WireObjectDelete
+
+/** Spec: MCL2 - no attributes */
+internal object WireMapClear
+
+/** Spec: MCRO2 */
+internal data class WireMapCreateWithObjectId(
+ val initialValue: String, // MCRO2a
+ val nonce: String, // MCRO2b
+ @Transient val derivedFrom: WireMapCreate? = null, // RTLMV4j5 - local use only
+)
+
+/** Spec: CCRO2 */
+internal data class WireCounterCreateWithObjectId(
+ val initialValue: String, // CCRO2a
+ val nonce: String, // CCRO2b
+ @Transient val derivedFrom: WireCounterCreate? = null, // RTLCV4g5 - local use only
+)
+
+/** Spec: OME2 */
+internal data class WireObjectsMapEntry(
+ val tombstone: Boolean? = null, // OME2a
+ val timeserial: String? = null, // OME2b
+ val serialTimestamp: Long? = null, // OME2d
+ val data: WireObjectData? = null, // OME2c
+)
+
+/** Spec: OMP1 */
+internal data class WireObjectsMap(
+ val semantics: WireObjectsMapSemantics? = null, // OMP3a
+ val entries: Map? = null, // OMP3b
+ val clearTimeserial: String? = null, // OMP3c
+)
+
+/** Spec: OCN1 */
+internal data class WireObjectsCounter(
+ val count: Double? = null, // OCN2a
+)
+
+/** Spec: OOP3 */
+internal data class WireObjectOperation(
+ val action: WireObjectOperationAction, // OOP3a
+ val objectId: String, // OOP3b
+ val mapCreate: WireMapCreate? = null, // OOP3j
+ val mapSet: WireMapSet? = null, // OOP3k
+ val mapRemove: WireMapRemove? = null, // OOP3l
+ val counterCreate: WireCounterCreate? = null, // OOP3m
+ val counterInc: WireCounterInc? = null, // OOP3n
+ val objectDelete: WireObjectDelete? = null, // OOP3o
+ val mapCreateWithObjectId: WireMapCreateWithObjectId? = null, // OOP3p
+ val counterCreateWithObjectId: WireCounterCreateWithObjectId? = null, // OOP3q
+ val mapClear: WireMapClear? = null, // OOP3r
+)
+
+/** Spec: OST1 */
+internal data class WireObjectState(
+ val objectId: String, // OST2a
+ val siteTimeserials: Map, // OST2b
+ val tombstone: Boolean, // OST2c
+ val createOp: WireObjectOperation? = null, // OST2d
+ val map: WireObjectsMap? = null, // OST2e
+ val counter: WireObjectsCounter? = null, // OST2f
+)
+
+/** Spec: OM2 */
+internal data class WireObjectMessage(
+ val id: String? = null, // OM2a
+ val timestamp: Long? = null, // OM2e
+ val clientId: String? = null, // OM2b
+ val connectionId: String? = null, // OM2c
+ val extras: JsonObject? = null, // OM2d
+ val operation: WireObjectOperation? = null, // OM2f
+ @SerializedName("object")
+ val objectState: WireObjectState? = null, // OM2g - wire key "object"
+ val serial: String? = null, // OM2h
+ val serialTimestamp: Long? = null, // OM2j
+ val siteCode: String? = null, // OM2i
+)
+
+/**
+ * Calculates the size of an ObjectMessage in bytes.
+ * Spec: OM3
+ */
+internal fun WireObjectMessage.size(): Int {
+ val clientIdSize = clientId?.byteSize ?: 0 // Spec: OM3f
+ val operationSize = operation?.size() ?: 0 // Spec: OM3b, OOP4
+ val objectStateSize = objectState?.size() ?: 0 // Spec: OM3c, OST3
+ val extrasSize = extras?.let { gson.toJson(it).length } ?: 0 // Spec: OM3d
+
+ return clientIdSize + operationSize + objectStateSize + extrasSize
+}
+
+/**
+ * Calculates the size of an ObjectOperation in bytes.
+ * Spec: OOP4
+ */
+private fun WireObjectOperation.size(): Int {
+ val mapCreateSize = mapCreate?.size() ?: mapCreateWithObjectId?.derivedFrom?.size() ?: 0
+ val mapSetSize = mapSet?.size() ?: 0
+ val mapRemoveSize = mapRemove?.size() ?: 0
+ val counterCreateSize = counterCreate?.size() ?: counterCreateWithObjectId?.derivedFrom?.size() ?: 0
+ val counterIncSize = counterInc?.size() ?: 0
+
+ return mapCreateSize + mapSetSize + mapRemoveSize +
+ counterCreateSize + counterIncSize
+}
+
+/**
+ * Calculates the size of an ObjectState in bytes.
+ * Spec: OST3
+ */
+private fun WireObjectState.size(): Int {
+ val mapSize = map?.size() ?: 0 // Spec: OST3b, OMP4
+ val counterSize = counter?.size() ?: 0 // Spec: OST3c, OCN3
+ val createOpSize = createOp?.size() ?: 0 // Spec: OST3d, OOP4
+
+ return mapSize + counterSize + createOpSize
+}
+
+/**
+ * Calculates the size of a MapCreate payload in bytes.
+ */
+private fun WireMapCreate.size(): Int {
+ return entries.entries.sumOf { it.key.byteSize + it.value.size() }
+}
+
+/**
+ * Calculates the size of a MapSet payload in bytes.
+ */
+private fun WireMapSet.size(): Int {
+ return key.byteSize + value.size()
+}
+
+/**
+ * Calculates the size of a MapRemove payload in bytes.
+ */
+private fun WireMapRemove.size(): Int {
+ return key.byteSize
+}
+
+/**
+ * Calculates the size of a CounterCreate payload in bytes.
+ */
+private fun WireCounterCreate.size(): Int {
+ return 8 // Double is 8 bytes
+}
+
+/**
+ * Calculates the size of a CounterInc payload in bytes.
+ */
+private fun WireCounterInc.size(): Int {
+ return 8 // Double is 8 bytes
+}
+
+/**
+ * Calculates the size of a MapCreateWithObjectId payload in bytes.
+ */
+private fun WireMapCreateWithObjectId.size(): Int {
+ return initialValue.byteSize + nonce.byteSize
+}
+
+/**
+ * Calculates the size of a CounterCreateWithObjectId payload in bytes.
+ */
+private fun WireCounterCreateWithObjectId.size(): Int {
+ return initialValue.byteSize + nonce.byteSize
+}
+
+/**
+ * Calculates the size of an ObjectMap in bytes.
+ * Spec: OMP4
+ */
+private fun WireObjectsMap.size(): Int {
+ // Calculate the size of all map entries in the map property
+ val entriesSize = entries?.entries?.sumOf {
+ it.key.length + it.value.size() // // Spec: OMP4a1, OMP4a2
+ } ?: 0
+
+ return entriesSize
+}
+
+/**
+ * Calculates the size of an ObjectCounter in bytes.
+ * Spec: OCN3
+ */
+private fun WireObjectsCounter.size(): Int {
+ // Size is 8 if count is a number, 0 if count is null or omitted
+ return if (count != null) 8 else 0
+}
+
+/**
+ * Calculates the size of a MapEntry in bytes.
+ * Spec: OME3
+ */
+private fun WireObjectsMapEntry.size(): Int {
+ // The size is equal to the size of the data property, calculated per "OD3"
+ return data?.size() ?: 0
+}
+
+/**
+ * Calculates the size of an ObjectData in bytes.
+ * Spec: OD3
+ */
+private fun WireObjectData.size(): Int {
+ string?.let { return it.byteSize } // Spec: OD3e
+ number?.let { return 8 } // Spec: OD3d
+ boolean?.let { return 1 } // Spec: OD3b
+ bytes?.let { return Base64.getDecoder().decode(it).size } // Spec: OD3c
+ json?.let { return it.toString().byteSize } // Spec: OD3e
+ return 0
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObject.kt
new file mode 100644
index 000000000..4cc3a38c8
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObject.kt
@@ -0,0 +1,102 @@
+package io.ably.lib.liveobjects.path
+
+import com.google.gson.JsonElement
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.Subscription
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.instance.Instance
+import io.ably.lib.liveobjects.onceSubscription
+import io.ably.lib.liveobjects.path.types.BinaryPathObject
+import io.ably.lib.liveobjects.path.types.BooleanPathObject
+import io.ably.lib.liveobjects.path.types.DefaultBinaryPathObject
+import io.ably.lib.liveobjects.path.types.DefaultBooleanPathObject
+import io.ably.lib.liveobjects.path.types.DefaultJsonArrayPathObject
+import io.ably.lib.liveobjects.path.types.DefaultJsonObjectPathObject
+import io.ably.lib.liveobjects.path.types.DefaultLiveCounterPathObject
+import io.ably.lib.liveobjects.path.types.DefaultLiveMapPathObject
+import io.ably.lib.liveobjects.path.types.DefaultNumberPathObject
+import io.ably.lib.liveobjects.path.types.DefaultStringPathObject
+import io.ably.lib.liveobjects.path.types.JsonArrayPathObject
+import io.ably.lib.liveobjects.path.types.JsonObjectPathObject
+import io.ably.lib.liveobjects.path.types.LiveCounterPathObject
+import io.ably.lib.liveobjects.path.types.LiveMapPathObject
+import io.ably.lib.liveobjects.path.types.NumberPathObject
+import io.ably.lib.liveobjects.path.types.StringPathObject
+import io.ably.lib.liveobjects.value.ResolvedValue
+import io.ably.lib.liveobjects.value.valueType
+
+/**
+ * Default implementation of [PathObject], the untyped node in the path-addressed view of
+ * the LiveObjects graph.
+ *
+ * This is a skeleton. The `as*` casts return a typed view of the same position; the
+ * operations that require resolving the path against the live objects graph are left
+ * unimplemented for now and will be filled in as the path-based API is built out.
+ *
+ * Spec: RTPO1, RTPO2, RTTS3
+ */
+internal open class DefaultPathObject(
+ internal val channelObject: DefaultRealtimeObject,
+ internal val path: String
+) : PathObject {
+
+ override fun path(): String = path
+
+ override fun getType(): ValueType? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ return resolveValueAtPath(path)?.valueType()
+ }
+
+ override fun instance(): Instance? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ val resolvedValue = resolveValueAtPath(path) ?: return null // unresolved path -> no instance
+ return when (resolvedValue) {
+ is ResolvedValue.Leaf -> null // primitives have no Instance; only live objects do
+ // TODO - wrap the resolved live object (LiveMap/LiveCounter) in an Instance
+ is ResolvedValue.MapRef, is ResolvedValue.CounterRef -> TODO("Not yet implemented")
+ }
+ }
+
+ override fun compactJson(): JsonElement? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ resolveValueAtPath(path) ?: return null // unresolved path -> null
+ // TODO - build the compacted JSON snapshot (LiveMap -> JsonObject, LiveCounter -> number, leaf -> JSON value)
+ TODO("Not yet implemented")
+ }
+
+ override fun exists(): Boolean {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ return resolveValueAtPath(path) != null
+ }
+
+ override fun asLiveMap(): LiveMapPathObject = DefaultLiveMapPathObject(channelObject, path)
+
+ override fun asLiveCounter(): LiveCounterPathObject = DefaultLiveCounterPathObject(channelObject, path)
+
+ override fun asNumber(): NumberPathObject = DefaultNumberPathObject(channelObject, path)
+
+ override fun asString(): StringPathObject = DefaultStringPathObject(channelObject, path)
+
+ override fun asBoolean(): BooleanPathObject = DefaultBooleanPathObject(channelObject, path)
+
+ override fun asBinary(): BinaryPathObject = DefaultBinaryPathObject(channelObject, path)
+
+ override fun asJsonObject(): JsonObjectPathObject = DefaultJsonObjectPathObject(channelObject, path)
+
+ override fun asJsonArray(): JsonArrayPathObject = DefaultJsonArrayPathObject(channelObject, path)
+
+ override fun subscribe(listener: PathObjectListener): Subscription = subscribe(listener, null)
+
+ override fun subscribe(listener: PathObjectListener, options: PathObjectSubscriptionOptions?): Subscription {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ // TODO - subscribe logic goes here
+ return onceSubscription {
+ // TODO - remove PathObjectListener from list
+ }
+ }
+
+ protected fun resolveValueAtPath(path: String): ResolvedValue? {
+ // TODO - resolve the path against the live objects graph and return the value at that position
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObjectSubscriptionEvent.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObjectSubscriptionEvent.kt
new file mode 100644
index 000000000..17e474807
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/DefaultPathObjectSubscriptionEvent.kt
@@ -0,0 +1,20 @@
+package io.ably.lib.liveobjects.path
+
+import io.ably.lib.liveobjects.message.ObjectMessage
+
+/**
+ * Default implementation of [PathObjectSubscriptionEvent], the event delivered to a
+ * [PathObjectListener] when a change affects the subscribed path. A plain holder for the
+ * changed [PathObject] and the source [ObjectMessage] (if any).
+ *
+ * Spec: RTPO19e / RTTS3d
+ */
+internal class DefaultPathObjectSubscriptionEvent(
+ private val pathObject: PathObject,
+ private val message: ObjectMessage?,
+) : PathObjectSubscriptionEvent {
+
+ override fun getObject(): PathObject = pathObject
+
+ override fun getMessage(): ObjectMessage? = message
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBinaryPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBinaryPathObject.kt
new file mode 100644
index 000000000..35ac94c5d
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBinaryPathObject.kt
@@ -0,0 +1,25 @@
+package io.ably.lib.liveobjects.path.types
+
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.path.DefaultPathObject
+import io.ably.lib.liveobjects.value.valueType
+
+/**
+ * Default implementation of [BinaryPathObject], a terminal primitive view that only adds a
+ * type-narrowed [value]; left unimplemented for now.
+ *
+ * Spec: RTTS6c
+ */
+internal class DefaultBinaryPathObject(
+ channelObject: DefaultRealtimeObject,
+ path: String,
+) : DefaultPathObject(channelObject, path), BinaryPathObject {
+
+ override fun value(): ByteArray? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path)?.valueType() != ValueType.BINARY) return null // not a Binary value at this path -> no value
+ // TODO - extract the primitive value from the resolved leaf, narrowed to ByteArray (base64-decoded)
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBooleanPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBooleanPathObject.kt
new file mode 100644
index 000000000..e8554f780
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultBooleanPathObject.kt
@@ -0,0 +1,25 @@
+package io.ably.lib.liveobjects.path.types
+
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.path.DefaultPathObject
+import io.ably.lib.liveobjects.value.valueType
+
+/**
+ * Default implementation of [BooleanPathObject], a terminal primitive view that only adds a
+ * type-narrowed [value]; left unimplemented for now.
+ *
+ * Spec: RTTS6c
+ */
+internal class DefaultBooleanPathObject(
+ channelObject: DefaultRealtimeObject,
+ path: String,
+) : DefaultPathObject(channelObject, path), BooleanPathObject {
+
+ override fun value(): Boolean? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path)?.valueType() != ValueType.BOOLEAN) return null // not a Boolean at this path -> no value
+ // TODO - extract the primitive value from the resolved leaf, narrowed to Boolean
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonArrayPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonArrayPathObject.kt
new file mode 100644
index 000000000..fa40c460f
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonArrayPathObject.kt
@@ -0,0 +1,26 @@
+package io.ably.lib.liveobjects.path.types
+
+import com.google.gson.JsonArray
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.path.DefaultPathObject
+import io.ably.lib.liveobjects.value.valueType
+
+/**
+ * Default implementation of [JsonArrayPathObject], a terminal primitive view that only adds
+ * a type-narrowed [value]; left unimplemented for now.
+ *
+ * Spec: RTTS6c
+ */
+internal class DefaultJsonArrayPathObject(
+ channelObject: DefaultRealtimeObject,
+ path: String,
+) : DefaultPathObject(channelObject, path), JsonArrayPathObject {
+
+ override fun value(): JsonArray? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path)?.valueType() != ValueType.JSON_ARRAY) return null // not a JSON array at this path -> no value
+ // TODO - extract the primitive value from the resolved leaf, narrowed to JsonArray
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonObjectPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonObjectPathObject.kt
new file mode 100644
index 000000000..e9362fcfe
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultJsonObjectPathObject.kt
@@ -0,0 +1,26 @@
+package io.ably.lib.liveobjects.path.types
+
+import com.google.gson.JsonObject
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.path.DefaultPathObject
+import io.ably.lib.liveobjects.value.valueType
+
+/**
+ * Default implementation of [JsonObjectPathObject], a terminal primitive view that only adds
+ * a type-narrowed [value]; left unimplemented for now.
+ *
+ * Spec: RTTS6c
+ */
+internal class DefaultJsonObjectPathObject(
+ channelObject: DefaultRealtimeObject,
+ path: String,
+) : DefaultPathObject(channelObject, path), JsonObjectPathObject {
+
+ override fun value(): JsonObject? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path)?.valueType() != ValueType.JSON_OBJECT) return null // not a JSON object at this path -> no value
+ // TODO - extract the primitive value from the resolved leaf, narrowed to JsonObject
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveCounterPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveCounterPathObject.kt
new file mode 100644
index 000000000..6e4e320ca
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveCounterPathObject.kt
@@ -0,0 +1,69 @@
+package io.ably.lib.liveobjects.path.types
+
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.path.DefaultPathObject
+import io.ably.lib.liveobjects.pathNotResolvedError
+import io.ably.lib.liveobjects.typeMismatchError
+import io.ably.lib.liveobjects.value.ResolvedValue
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Default implementation of [LiveCounterPathObject].
+ *
+ * Counters are terminal nodes (no navigation), so this only adds the counter read/write
+ * operations on top of [DefaultPathObject]; they are left unimplemented for now.
+ *
+ * Spec: RTTS6b
+ */
+internal class DefaultLiveCounterPathObject(
+ channelObject: DefaultRealtimeObject,
+ path: String,
+) : DefaultPathObject(channelObject, path), LiveCounterPathObject {
+
+ override fun value(): Double? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path) !is ResolvedValue.CounterRef) return null // not a LiveCounter (or unresolved) -> null
+ // TODO - return the resolved counter's value
+ TODO("Not yet implemented")
+ }
+
+ override fun increment(): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path)
+ if (resolvedValue !is ResolvedValue.CounterRef) {
+ throw typeMismatchError("Cannot increment a non-LiveCounter object at path: \"$path\"")
+ }
+ // TODO - delegate the COUNTER_INC (amount 1) to the resolved LiveCounter
+ TODO("Not yet implemented")
+ }
+
+ override fun increment(amount: Number): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path)
+ if (resolvedValue !is ResolvedValue.CounterRef) {
+ throw typeMismatchError("Cannot increment a non-LiveCounter object at path: \"$path\"")
+ }
+ // TODO - delegate the COUNTER_INC to the resolved LiveCounter
+ TODO("Not yet implemented")
+ }
+
+ override fun decrement(): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path)
+ if (resolvedValue !is ResolvedValue.CounterRef) {
+ throw typeMismatchError("Cannot decrement a non-LiveCounter object at path: \"$path\"")
+ }
+ // TODO - delegate the COUNTER_INC (negated amount 1) to the resolved LiveCounter
+ TODO("Not yet implemented")
+ }
+
+ override fun decrement(amount: Number): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path)
+ if (resolvedValue !is ResolvedValue.CounterRef) {
+ throw typeMismatchError("Cannot decrement a non-LiveCounter object at path: \"$path\"")
+ }
+ // TODO - delegate the COUNTER_INC (negated amount) to the resolved LiveCounter
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveMapPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveMapPathObject.kt
new file mode 100644
index 000000000..8c6a561d5
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveMapPathObject.kt
@@ -0,0 +1,74 @@
+package io.ably.lib.liveobjects.path.types
+
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.path.DefaultPathObject
+import io.ably.lib.liveobjects.path.PathObject
+import io.ably.lib.liveobjects.pathNotResolvedError
+import io.ably.lib.liveobjects.typeMismatchError
+import io.ably.lib.liveobjects.value.LiveMapValue
+import io.ably.lib.liveobjects.value.ResolvedValue
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Default implementation of [LiveMapPathObject], adding map navigation and read/write
+ * operations on top of [DefaultPathObject]; all left unimplemented for now.
+ *
+ * Spec: RTTS6a
+ */
+internal class DefaultLiveMapPathObject(
+ channelObject: DefaultRealtimeObject,
+ path: String,
+) : DefaultPathObject(channelObject, path), LiveMapPathObject {
+
+ override fun get(key: String): PathObject = TODO("Not yet implemented")
+
+ override fun at(path: String): PathObject = TODO("Not yet implemented")
+
+ override fun entries(): Iterable> {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty
+ // TODO - iterate the resolved map's entries, yielding (key, child PathObject)
+ TODO("Not yet implemented")
+ }
+
+ override fun keys(): Iterable {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty
+ // TODO - return the resolved map's keys
+ TODO("Not yet implemented")
+ }
+
+ override fun values(): Iterable {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return emptyList() // not a LiveMap (or unresolved) -> empty
+ // TODO - return a child PathObject for each entry of the resolved map
+ TODO("Not yet implemented")
+ }
+
+ override fun size(): Long? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path) !is ResolvedValue.MapRef) return null // not a LiveMap (or unresolved) -> null
+ // TODO - return the resolved map's size
+ TODO("Not yet implemented")
+ }
+
+ override fun set(key: String, value: LiveMapValue): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path)
+ if (resolvedValue !is ResolvedValue.MapRef) {
+ throw typeMismatchError("Cannot set a key on a non-LiveMap object at path: \"$path\"")
+ }
+ // TODO - delegate the MAP_SET to the resolved LiveMap
+ TODO("Not yet implemented")
+ }
+
+ override fun remove(key: String): CompletableFuture {
+ channelObject.throwIfInvalidWriteApiConfiguration()
+ val resolvedValue = resolveValueAtPath(path) ?: throw pathNotResolvedError(path)
+ if (resolvedValue !is ResolvedValue.MapRef) {
+ throw typeMismatchError("Cannot remove a key from a non-LiveMap object at path: \"$path\"")
+ }
+ // TODO - delegate the MAP_REMOVE to the resolved LiveMap
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultNumberPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultNumberPathObject.kt
new file mode 100644
index 000000000..bc64dd28c
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultNumberPathObject.kt
@@ -0,0 +1,25 @@
+package io.ably.lib.liveobjects.path.types
+
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.path.DefaultPathObject
+import io.ably.lib.liveobjects.value.valueType
+
+/**
+ * Default implementation of [NumberPathObject], a terminal primitive view that only adds a
+ * type-narrowed [value]; left unimplemented for now.
+ *
+ * Spec: RTTS6c
+ */
+internal class DefaultNumberPathObject(
+ channelObject: DefaultRealtimeObject,
+ path: String,
+) : DefaultPathObject(channelObject, path), NumberPathObject {
+
+ override fun value(): Number? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path)?.valueType() != ValueType.NUMBER) return null // not a Number at this path -> no value
+ // TODO - extract the primitive value from the resolved leaf, narrowed to Number
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultStringPathObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultStringPathObject.kt
new file mode 100644
index 000000000..4275c84c4
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultStringPathObject.kt
@@ -0,0 +1,25 @@
+package io.ably.lib.liveobjects.path.types
+
+import io.ably.lib.liveobjects.DefaultRealtimeObject
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.path.DefaultPathObject
+import io.ably.lib.liveobjects.value.valueType
+
+/**
+ * Default implementation of [StringPathObject], a terminal primitive view that only adds a
+ * type-narrowed [value]; left unimplemented for now.
+ *
+ * Spec: RTTS6c
+ */
+internal class DefaultStringPathObject(
+ channelObject: DefaultRealtimeObject,
+ path: String,
+) : DefaultPathObject(channelObject, path), StringPathObject {
+
+ override fun value(): String? {
+ channelObject.throwIfInvalidAccessApiConfiguration()
+ if (resolveValueAtPath(path)?.valueType() != ValueType.STRING) return null // not a String at this path -> no value
+ // TODO - extract the primitive value from the resolved leaf, narrowed to String
+ TODO("Not yet implemented")
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/DefaultSerialization.kt
similarity index 61%
rename from liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt
rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/DefaultSerialization.kt
index 8267a360d..7deb4fe24 100644
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/DefaultSerialization.kt
@@ -1,19 +1,17 @@
-package io.ably.lib.objects.serialization
+package io.ably.lib.liveobjects.serialization
import com.google.gson.*
-import io.ably.lib.objects.*
-
-import io.ably.lib.objects.ObjectMessage
+import io.ably.lib.liveobjects.message.WireObjectMessage
import org.msgpack.core.MessagePacker
import org.msgpack.core.MessageUnpacker
/**
- * Default implementation of {@link ObjectsSerializer} that handles serialization/deserialization
- * of ObjectMessage arrays for both JSON and MessagePack formats using Jackson and Gson.
- * Dynamically loaded by ObjectsHelper#getSerializer() to avoid hard dependencies.
+ * Default implementation of {@link ObjectSerializer} that handles serialization/deserialization
+ * of WireObjectMessage arrays for both JSON and MessagePack formats using Gson and MessagePack.
+ * Dynamically loaded by ObjectSerializer#tryGet() to avoid hard dependencies.
*/
-@Suppress("unused") // Used via reflection in ObjectsHelper
-internal class DefaultObjectsSerializer : ObjectsSerializer {
+@Suppress("unused") // Used via reflection in ObjectSerializer.Holder
+internal class DefaultObjectsSerializer : ObjectSerializer {
override fun readMsgpackArray(unpacker: MessageUnpacker): Array {
val objectMessagesCount = unpacker.unpackArrayHeader()
@@ -21,7 +19,7 @@ internal class DefaultObjectsSerializer : ObjectsSerializer {
}
override fun writeMsgpackArray(objects: Array, packer: MessagePacker) {
- val objectMessages = objects.map { it as ObjectMessage }
+ val objectMessages = objects.map { it as WireObjectMessage }
packer.packArrayHeader(objectMessages.size)
objectMessages.forEach { it.writeMsgpack(packer) }
}
@@ -34,7 +32,7 @@ internal class DefaultObjectsSerializer : ObjectsSerializer {
}
override fun asJsonArray(objects: Array): JsonArray {
- val objectMessages = objects.map { it as ObjectMessage }
+ val objectMessages = objects.map { it as WireObjectMessage }
val jsonArray = JsonArray()
for (objectMessage in objectMessages) {
jsonArray.add(objectMessage.toJsonObject())
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/JsonSerialization.kt
similarity index 65%
rename from liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt
rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/JsonSerialization.kt
index fbf5acb88..3620dfee9 100644
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/JsonSerialization.kt
@@ -1,25 +1,25 @@
-package io.ably.lib.objects.serialization
+package io.ably.lib.liveobjects.serialization
import com.google.gson.*
-import io.ably.lib.objects.ObjectsMapSemantics
-import io.ably.lib.objects.ObjectData
-import io.ably.lib.objects.ObjectMessage
-import io.ably.lib.objects.ObjectOperationAction
+import io.ably.lib.liveobjects.message.WireObjectData
+import io.ably.lib.liveobjects.message.WireObjectMessage
+import io.ably.lib.liveobjects.message.WireObjectOperationAction
+import io.ably.lib.liveobjects.message.WireObjectsMapSemantics
import java.lang.reflect.Type
import kotlin.enums.EnumEntries
// Gson instance for JSON serialization/deserialization
internal val gson = GsonBuilder()
- .registerTypeAdapter(ObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, ObjectOperationAction.entries))
- .registerTypeAdapter(ObjectsMapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, ObjectsMapSemantics.entries))
+ .registerTypeAdapter(WireObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, WireObjectOperationAction.entries))
+ .registerTypeAdapter(WireObjectsMapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, WireObjectsMapSemantics.entries))
.create()
-internal fun ObjectMessage.toJsonObject(): JsonObject {
+internal fun WireObjectMessage.toJsonObject(): JsonObject {
return gson.toJsonTree(this).asJsonObject
}
-internal fun JsonObject.toObjectMessage(): ObjectMessage {
- return gson.fromJson(this, ObjectMessage::class.java)
+internal fun JsonObject.toObjectMessage(): WireObjectMessage {
+ return gson.fromJson(this, WireObjectMessage::class.java)
}
internal class EnumCodeTypeAdapter>(
@@ -38,8 +38,8 @@ internal class EnumCodeTypeAdapter>(
}
}
-internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeserializer {
- override fun serialize(src: ObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
+internal class WireObjectDataJsonSerializer : JsonSerializer, JsonDeserializer {
+ override fun serialize(src: WireObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
val obj = JsonObject()
src.objectId?.let { obj.addProperty("objectId", it) }
src.string?.let { obj.addProperty("string", it) }
@@ -50,7 +50,7 @@ internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeseri
return obj
}
- override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): ObjectData {
+ override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): WireObjectData {
val obj = if (json.isJsonObject) json.asJsonObject else throw JsonParseException("Expected JsonObject")
val objectId = if (obj.has("objectId")) obj.get("objectId").asString else null
val string = if (obj.has("string")) obj.get("string").asString else null
@@ -62,6 +62,6 @@ internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeseri
if (objectId == null && string == null && number == null && boolean == null && bytes == null && json == null) {
throw JsonParseException("Since objectId is not present, at least one of the value fields must be present")
}
- return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json)
+ return WireObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json)
}
}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt
similarity index 70%
rename from liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt
rename to liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt
index 2eb10d0bd..18d5c0701 100644
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/serialization/MsgpackSerialization.kt
@@ -1,38 +1,37 @@
-package io.ably.lib.objects.serialization
+package io.ably.lib.liveobjects.serialization
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
-import io.ably.lib.objects.*
-import io.ably.lib.objects.CounterCreate
-import io.ably.lib.objects.CounterCreateWithObjectId
-import io.ably.lib.objects.CounterInc
-import io.ably.lib.objects.ErrorCode
-import io.ably.lib.objects.MapCreate
-import io.ably.lib.objects.MapCreateWithObjectId
-import io.ably.lib.objects.MapRemove
-import io.ably.lib.objects.MapSet
-import io.ably.lib.objects.MapClear
-import io.ably.lib.objects.ObjectDelete
-import io.ably.lib.objects.ObjectsMapSemantics
-import io.ably.lib.objects.ObjectsCounter
-import io.ably.lib.objects.ObjectData
-import io.ably.lib.objects.ObjectsMap
-import io.ably.lib.objects.ObjectsMapEntry
-import io.ably.lib.objects.ObjectMessage
-import io.ably.lib.objects.ObjectOperation
-import io.ably.lib.objects.ObjectOperationAction
-import io.ably.lib.objects.ObjectState
-import java.util.Base64
+import io.ably.lib.liveobjects.message.WireCounterCreate
+import io.ably.lib.liveobjects.message.WireCounterCreateWithObjectId
+import io.ably.lib.liveobjects.message.WireCounterInc
+import io.ably.lib.liveobjects.message.WireMapClear
+import io.ably.lib.liveobjects.message.WireMapCreate
+import io.ably.lib.liveobjects.message.WireMapCreateWithObjectId
+import io.ably.lib.liveobjects.message.WireMapRemove
+import io.ably.lib.liveobjects.message.WireMapSet
+import io.ably.lib.liveobjects.message.WireObjectData
+import io.ably.lib.liveobjects.message.WireObjectDelete
+import io.ably.lib.liveobjects.message.WireObjectMessage
+import io.ably.lib.liveobjects.message.WireObjectOperation
+import io.ably.lib.liveobjects.message.WireObjectOperationAction
+import io.ably.lib.liveobjects.message.WireObjectState
+import io.ably.lib.liveobjects.message.WireObjectsCounter
+import io.ably.lib.liveobjects.message.WireObjectsMap
+import io.ably.lib.liveobjects.message.WireObjectsMapEntry
+import io.ably.lib.liveobjects.message.WireObjectsMapSemantics
+import io.ably.lib.liveobjects.objectStateError
import io.ably.lib.util.Serialisation
+import java.util.Base64
import org.msgpack.core.MessageFormat
import org.msgpack.core.MessagePacker
import org.msgpack.core.MessageUnpacker
/**
- * Write ObjectMessage to MessagePacker
+ * Write WireObjectMessage to MessagePacker
*/
-internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) {
+internal fun WireObjectMessage.writeMsgpack(packer: MessagePacker) {
var fieldCount = 0
if (id != null) fieldCount++
@@ -100,12 +99,12 @@ internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) {
}
/**
- * Read an ObjectMessage from MessageUnpacker
+ * Read a WireObjectMessage from MessageUnpacker
*/
-internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage {
+internal fun readObjectMessage(unpacker: MessageUnpacker): WireObjectMessage {
if (unpacker.nextFormat == MessageFormat.NIL) {
unpacker.unpackNil()
- return ObjectMessage() // default/empty message
+ return WireObjectMessage() // default/empty message
}
val fieldCount = unpacker.unpackMapHeader()
@@ -115,8 +114,8 @@ internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage {
var clientId: String? = null
var connectionId: String? = null
var extras: JsonObject? = null
- var operation: ObjectOperation? = null
- var objectState: ObjectState? = null
+ var operation: WireObjectOperation? = null
+ var objectState: WireObjectState? = null
var serial: String? = null
var serialTimestamp: Long? = null
var siteCode: String? = null
@@ -145,7 +144,7 @@ internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage {
}
}
- return ObjectMessage(
+ return WireObjectMessage(
id = id,
timestamp = timestamp,
clientId = clientId,
@@ -160,9 +159,9 @@ internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage {
}
/**
- * Write ObjectOperation to MessagePacker
+ * Write WireObjectOperation to MessagePacker
*/
-private fun ObjectOperation.writeMsgpack(packer: MessagePacker) {
+private fun WireObjectOperation.writeMsgpack(packer: MessagePacker) {
var fieldCount = 1 // action is always required
require(objectId.isNotEmpty()) { "objectId must be non-empty per Objects protocol" }
fieldCount++
@@ -234,22 +233,22 @@ private fun ObjectOperation.writeMsgpack(packer: MessagePacker) {
}
/**
- * Read ObjectOperation from MessageUnpacker
+ * Read WireObjectOperation from MessageUnpacker
*/
-private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation {
+private fun readObjectOperation(unpacker: MessageUnpacker): WireObjectOperation {
val fieldCount = unpacker.unpackMapHeader()
- var action: ObjectOperationAction? = null
+ var action: WireObjectOperationAction? = null
var objectId: String = ""
- var mapCreate: MapCreate? = null
- var mapSet: MapSet? = null
- var mapRemove: MapRemove? = null
- var counterCreate: CounterCreate? = null
- var counterInc: CounterInc? = null
- var objectDelete: ObjectDelete? = null
- var mapCreateWithObjectId: MapCreateWithObjectId? = null
- var counterCreateWithObjectId: CounterCreateWithObjectId? = null
- var mapClear: MapClear? = null
+ var mapCreate: WireMapCreate? = null
+ var mapSet: WireMapSet? = null
+ var mapRemove: WireMapRemove? = null
+ var counterCreate: WireCounterCreate? = null
+ var counterInc: WireCounterInc? = null
+ var objectDelete: WireObjectDelete? = null
+ var mapCreateWithObjectId: WireMapCreateWithObjectId? = null
+ var counterCreateWithObjectId: WireCounterCreateWithObjectId? = null
+ var mapClear: WireMapClear? = null
for (i in 0 until fieldCount) {
val fieldName = unpacker.unpackString().intern()
@@ -263,9 +262,9 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation {
when (fieldName) {
"action" -> {
val actionCode = unpacker.unpackInt()
- action = ObjectOperationAction.entries.firstOrNull { it.code == actionCode }
- ?: ObjectOperationAction.entries.firstOrNull { it.code == -1 }
- ?: throw objectError("Unknown ObjectOperationAction code: $actionCode and no Unknown fallback found")
+ action = WireObjectOperationAction.entries.firstOrNull { it.code == actionCode }
+ ?: WireObjectOperationAction.entries.firstOrNull { it.code == -1 }
+ ?: throw objectStateError("Unknown WireObjectOperationAction code: $actionCode and no Unknown fallback found")
}
"objectId" -> objectId = unpacker.unpackString()
"mapCreate" -> mapCreate = readMapCreate(unpacker)
@@ -275,23 +274,27 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation {
"counterInc" -> counterInc = readCounterInc(unpacker)
"objectDelete" -> {
unpacker.skipValue() // empty map, just consume it
- objectDelete = ObjectDelete
+ objectDelete = WireObjectDelete
}
"mapCreateWithObjectId" -> mapCreateWithObjectId = readMapCreateWithObjectId(unpacker)
"counterCreateWithObjectId" -> counterCreateWithObjectId = readCounterCreateWithObjectId(unpacker)
"mapClear" -> {
unpacker.skipValue() // empty map, consume it
- mapClear = MapClear
+ mapClear = WireMapClear
}
else -> unpacker.skipValue()
}
}
if (action == null) {
- throw objectError("Missing required 'action' field in ObjectOperation")
+ throw objectStateError("Missing required 'action' field in WireObjectOperation")
+ }
+
+ if (objectId.isEmpty()) {
+ throw objectStateError("Missing required 'objectId' field in WireObjectOperation")
}
- return ObjectOperation(
+ return WireObjectOperation(
action = action,
objectId = objectId,
mapCreate = mapCreate,
@@ -307,9 +310,9 @@ private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation {
}
/**
- * Write ObjectState to MessagePacker
+ * Write WireObjectState to MessagePacker
*/
-private fun ObjectState.writeMsgpack(packer: MessagePacker) {
+private fun WireObjectState.writeMsgpack(packer: MessagePacker) {
var fieldCount = 3 // objectId, siteTimeserials, and tombstone are required
if (createOp != null) fieldCount++
@@ -348,17 +351,17 @@ private fun ObjectState.writeMsgpack(packer: MessagePacker) {
}
/**
- * Read ObjectState from MessageUnpacker
+ * Read WireObjectState from MessageUnpacker
*/
-private fun readObjectState(unpacker: MessageUnpacker): ObjectState {
+private fun readObjectState(unpacker: MessageUnpacker): WireObjectState {
val fieldCount = unpacker.unpackMapHeader()
var objectId = ""
var siteTimeserials = mapOf()
var tombstone = false
- var createOp: ObjectOperation? = null
- var map: ObjectsMap? = null
- var counter: ObjectsCounter? = null
+ var createOp: WireObjectOperation? = null
+ var map: WireObjectsMap? = null
+ var counter: WireObjectsCounter? = null
for (i in 0 until fieldCount) {
val fieldName = unpacker.unpackString().intern()
@@ -389,7 +392,11 @@ private fun readObjectState(unpacker: MessageUnpacker): ObjectState {
}
}
- return ObjectState(
+ if (objectId.isEmpty()) {
+ throw objectStateError("Missing required 'objectId' field in WireObjectState")
+ }
+
+ return WireObjectState(
objectId = objectId,
siteTimeserials = siteTimeserials,
tombstone = tombstone,
@@ -400,9 +407,9 @@ private fun readObjectState(unpacker: MessageUnpacker): ObjectState {
}
/**
- * Write MapCreate to MessagePacker
+ * Write WireMapCreate to MessagePacker
*/
-private fun MapCreate.writeMsgpack(packer: MessagePacker) {
+private fun WireMapCreate.writeMsgpack(packer: MessagePacker) {
packer.packMapHeader(2)
packer.packString("semantics")
packer.packInt(semantics.code)
@@ -415,12 +422,12 @@ private fun MapCreate.writeMsgpack(packer: MessagePacker) {
}
/**
- * Read MapCreate from MessageUnpacker
+ * Read WireMapCreate from MessageUnpacker
*/
-private fun readMapCreate(unpacker: MessageUnpacker): MapCreate {
+private fun readMapCreate(unpacker: MessageUnpacker): WireMapCreate {
val fieldCount = unpacker.unpackMapHeader()
- var semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW
- var entries: Map = emptyMap()
+ var semantics: WireObjectsMapSemantics = WireObjectsMapSemantics.LWW
+ var entries: Map = emptyMap()
for (i in 0 until fieldCount) {
val fieldName = unpacker.unpackString().intern()
@@ -429,13 +436,13 @@ private fun readMapCreate(unpacker: MessageUnpacker): MapCreate {
when (fieldName) {
"semantics" -> {
val code = unpacker.unpackInt()
- semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == code }
- ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 }
- ?: throw objectError("Unknown MapSemantics code: $code and no UNKNOWN fallback found")
+ semantics = WireObjectsMapSemantics.entries.firstOrNull { it.code == code }
+ ?: WireObjectsMapSemantics.entries.firstOrNull { it.code == -1 }
+ ?: throw objectStateError("Unknown MapSemantics code: $code and no UNKNOWN fallback found")
}
"entries" -> {
val mapSize = unpacker.unpackMapHeader()
- val tempMap = mutableMapOf()
+ val tempMap = mutableMapOf()
for (j in 0 until mapSize) {
tempMap[unpacker.unpackString()] = readObjectMapEntry(unpacker)
}
@@ -444,13 +451,13 @@ private fun readMapCreate(unpacker: MessageUnpacker): MapCreate {
else -> unpacker.skipValue()
}
}
- return MapCreate(semantics = semantics, entries = entries)
+ return WireMapCreate(semantics = semantics, entries = entries)
}
/**
- * Write MapSet to MessagePacker
+ * Write WireMapSet to MessagePacker
*/
-private fun MapSet.writeMsgpack(packer: MessagePacker) {
+private fun WireMapSet.writeMsgpack(packer: MessagePacker) {
packer.packMapHeader(2)
packer.packString("key")
packer.packString(key)
@@ -459,12 +466,12 @@ private fun MapSet.writeMsgpack(packer: MessagePacker) {
}
/**
- * Read MapSet from MessageUnpacker
+ * Read WireMapSet from MessageUnpacker
*/
-private fun readMapSet(unpacker: MessageUnpacker): MapSet {
+private fun readMapSet(unpacker: MessageUnpacker): WireMapSet {
val fieldCount = unpacker.unpackMapHeader()
var key: String? = null
- var value: ObjectData? = null
+ var value: WireObjectData? = null
for (i in 0 until fieldCount) {
val fieldName = unpacker.unpackString().intern()
@@ -476,25 +483,25 @@ private fun readMapSet(unpacker: MessageUnpacker): MapSet {
else -> unpacker.skipValue()
}
}
- return MapSet(
- key = key ?: throw objectError("Missing 'key' in MapSet payload"),
- value = value ?: throw objectError("Missing 'value' in MapSet payload")
+ return WireMapSet(
+ key = key ?: throw objectStateError("Missing 'key' in WireMapSet payload"),
+ value = value ?: throw objectStateError("Missing 'value' in WireMapSet payload")
)
}
/**
- * Write MapRemove to MessagePacker
+ * Write WireMapRemove to MessagePacker
*/
-private fun MapRemove.writeMsgpack(packer: MessagePacker) {
+private fun WireMapRemove.writeMsgpack(packer: MessagePacker) {
packer.packMapHeader(1)
packer.packString("key")
packer.packString(key)
}
/**
- * Read MapRemove from MessageUnpacker
+ * Read WireMapRemove from MessageUnpacker
*/
-private fun readMapRemove(unpacker: MessageUnpacker): MapRemove {
+private fun readMapRemove(unpacker: MessageUnpacker): WireMapRemove {
val fieldCount = unpacker.unpackMapHeader()
var key: String? = null
@@ -507,22 +514,22 @@ private fun readMapRemove(unpacker: MessageUnpacker): MapRemove {
else -> unpacker.skipValue()
}
}
- return MapRemove(key = key ?: throw objectError("Missing 'key' in MapRemove payload"))
+ return WireMapRemove(key = key ?: throw objectStateError("Missing 'key' in WireMapRemove payload"))
}
/**
- * Write CounterCreate to MessagePacker
+ * Write WireCounterCreate to MessagePacker
*/
-private fun CounterCreate.writeMsgpack(packer: MessagePacker) {
+private fun WireCounterCreate.writeMsgpack(packer: MessagePacker) {
packer.packMapHeader(1)
packer.packString("count")
packer.packDouble(count)
}
/**
- * Read CounterCreate from MessageUnpacker
+ * Read WireCounterCreate from MessageUnpacker
*/
-private fun readCounterCreate(unpacker: MessageUnpacker): CounterCreate {
+private fun readCounterCreate(unpacker: MessageUnpacker): WireCounterCreate {
val fieldCount = unpacker.unpackMapHeader()
var count: Double? = null
@@ -535,22 +542,22 @@ private fun readCounterCreate(unpacker: MessageUnpacker): CounterCreate {
else -> unpacker.skipValue()
}
}
- return CounterCreate(count = count ?: throw objectError("Missing 'count' in CounterCreate payload"))
+ return WireCounterCreate(count = count ?: throw objectStateError("Missing 'count' in WireCounterCreate payload"))
}
/**
- * Write CounterInc to MessagePacker
+ * Write WireCounterInc to MessagePacker
*/
-private fun CounterInc.writeMsgpack(packer: MessagePacker) {
+private fun WireCounterInc.writeMsgpack(packer: MessagePacker) {
packer.packMapHeader(1)
packer.packString("number")
packer.packDouble(number)
}
/**
- * Read CounterInc from MessageUnpacker
+ * Read WireCounterInc from MessageUnpacker
*/
-private fun readCounterInc(unpacker: MessageUnpacker): CounterInc {
+private fun readCounterInc(unpacker: MessageUnpacker): WireCounterInc {
val fieldCount = unpacker.unpackMapHeader()
var number: Double? = null
@@ -563,13 +570,13 @@ private fun readCounterInc(unpacker: MessageUnpacker): CounterInc {
else -> unpacker.skipValue()
}
}
- return CounterInc(number = number ?: throw objectError("Missing 'number' in CounterInc payload"))
+ return WireCounterInc(number = number ?: throw objectStateError("Missing 'number' in WireCounterInc payload"))
}
/**
- * Write MapCreateWithObjectId to MessagePacker
+ * Write WireMapCreateWithObjectId to MessagePacker
*/
-private fun MapCreateWithObjectId.writeMsgpack(packer: MessagePacker) {
+private fun WireMapCreateWithObjectId.writeMsgpack(packer: MessagePacker) {
packer.packMapHeader(2)
packer.packString("initialValue")
packer.packString(initialValue)
@@ -578,9 +585,9 @@ private fun MapCreateWithObjectId.writeMsgpack(packer: MessagePacker) {
}
/**
- * Read MapCreateWithObjectId from MessageUnpacker
+ * Read WireMapCreateWithObjectId from MessageUnpacker
*/
-private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): MapCreateWithObjectId {
+private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): WireMapCreateWithObjectId {
val fieldCount = unpacker.unpackMapHeader()
var initialValue: String? = null
var nonce: String? = null
@@ -595,16 +602,16 @@ private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): MapCreateWithO
else -> unpacker.skipValue()
}
}
- return MapCreateWithObjectId(
- initialValue = initialValue ?: throw objectError("Missing 'initialValue' in MapCreateWithObjectId payload"),
- nonce = nonce ?: throw objectError("Missing 'nonce' in MapCreateWithObjectId payload")
+ return WireMapCreateWithObjectId(
+ initialValue = initialValue ?: throw objectStateError("Missing 'initialValue' in WireMapCreateWithObjectId payload"),
+ nonce = nonce ?: throw objectStateError("Missing 'nonce' in WireMapCreateWithObjectId payload")
)
}
/**
- * Write CounterCreateWithObjectId to MessagePacker
+ * Write WireCounterCreateWithObjectId to MessagePacker
*/
-private fun CounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) {
+private fun WireCounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) {
packer.packMapHeader(2)
packer.packString("initialValue")
packer.packString(initialValue)
@@ -613,9 +620,9 @@ private fun CounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) {
}
/**
- * Read CounterCreateWithObjectId from MessageUnpacker
+ * Read WireCounterCreateWithObjectId from MessageUnpacker
*/
-private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): CounterCreateWithObjectId {
+private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): WireCounterCreateWithObjectId {
val fieldCount = unpacker.unpackMapHeader()
var initialValue: String? = null
var nonce: String? = null
@@ -630,16 +637,16 @@ private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): CounterCre
else -> unpacker.skipValue()
}
}
- return CounterCreateWithObjectId(
- initialValue = initialValue ?: throw objectError("Missing 'initialValue' in CounterCreateWithObjectId payload"),
- nonce = nonce ?: throw objectError("Missing 'nonce' in CounterCreateWithObjectId payload")
+ return WireCounterCreateWithObjectId(
+ initialValue = initialValue ?: throw objectStateError("Missing 'initialValue' in WireCounterCreateWithObjectId payload"),
+ nonce = nonce ?: throw objectStateError("Missing 'nonce' in WireCounterCreateWithObjectId payload")
)
}
/**
* Write ObjectMap to MessagePacker
*/
-private fun ObjectsMap.writeMsgpack(packer: MessagePacker) {
+private fun WireObjectsMap.writeMsgpack(packer: MessagePacker) {
var fieldCount = 0
if (semantics != null) fieldCount++
@@ -671,11 +678,11 @@ private fun ObjectsMap.writeMsgpack(packer: MessagePacker) {
/**
* Read ObjectMap from MessageUnpacker
*/
-private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap {
+private fun readObjectMap(unpacker: MessageUnpacker): WireObjectsMap {
val fieldCount = unpacker.unpackMapHeader()
- var semantics: ObjectsMapSemantics? = null
- var entries: Map? = null
+ var semantics: WireObjectsMapSemantics? = null
+ var entries: Map? = null
var clearTimeserial: String? = null
for (i in 0 until fieldCount) {
@@ -690,13 +697,13 @@ private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap {
when (fieldName) {
"semantics" -> {
val semanticsCode = unpacker.unpackInt()
- semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == semanticsCode }
- ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 }
- ?: throw objectError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found")
+ semantics = WireObjectsMapSemantics.entries.firstOrNull { it.code == semanticsCode }
+ ?: WireObjectsMapSemantics.entries.firstOrNull { it.code == -1 }
+ ?: throw objectStateError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found")
}
"entries" -> {
val mapSize = unpacker.unpackMapHeader()
- val tempMap = mutableMapOf()
+ val tempMap = mutableMapOf()
for (j in 0 until mapSize) {
val key = unpacker.unpackString()
val value = readObjectMapEntry(unpacker)
@@ -709,13 +716,13 @@ private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap {
}
}
- return ObjectsMap(semantics = semantics, entries = entries, clearTimeserial = clearTimeserial)
+ return WireObjectsMap(semantics = semantics, entries = entries, clearTimeserial = clearTimeserial)
}
/**
* Write ObjectCounter to MessagePacker
*/
-private fun ObjectsCounter.writeMsgpack(packer: MessagePacker) {
+private fun WireObjectsCounter.writeMsgpack(packer: MessagePacker) {
var fieldCount = 0
if (count != null) fieldCount++
@@ -731,7 +738,7 @@ private fun ObjectsCounter.writeMsgpack(packer: MessagePacker) {
/**
* Read ObjectCounter from MessageUnpacker
*/
-private fun readObjectCounter(unpacker: MessageUnpacker): ObjectsCounter {
+private fun readObjectCounter(unpacker: MessageUnpacker): WireObjectsCounter {
val fieldCount = unpacker.unpackMapHeader()
var count: Double? = null
@@ -751,13 +758,13 @@ private fun readObjectCounter(unpacker: MessageUnpacker): ObjectsCounter {
}
}
- return ObjectsCounter(count = count)
+ return WireObjectsCounter(count = count)
}
/**
* Write ObjectMapEntry to MessagePacker
*/
-private fun ObjectsMapEntry.writeMsgpack(packer: MessagePacker) {
+private fun WireObjectsMapEntry.writeMsgpack(packer: MessagePacker) {
var fieldCount = 0
if (tombstone != null) fieldCount++
@@ -791,13 +798,13 @@ private fun ObjectsMapEntry.writeMsgpack(packer: MessagePacker) {
/**
* Read ObjectMapEntry from MessageUnpacker
*/
-private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectsMapEntry {
+private fun readObjectMapEntry(unpacker: MessageUnpacker): WireObjectsMapEntry {
val fieldCount = unpacker.unpackMapHeader()
var tombstone: Boolean? = null
var timeserial: String? = null
var serialTimestamp: Long? = null
- var data: ObjectData? = null
+ var data: WireObjectData? = null
for (i in 0 until fieldCount) {
val fieldName = unpacker.unpackString().intern()
@@ -817,13 +824,13 @@ private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectsMapEntry {
}
}
- return ObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data)
+ return WireObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data)
}
/**
- * Write ObjectData to MessagePacker
+ * Write WireObjectData to MessagePacker
*/
-private fun ObjectData.writeMsgpack(packer: MessagePacker) {
+private fun WireObjectData.writeMsgpack(packer: MessagePacker) {
var fieldCount = 0
if (objectId != null) fieldCount++
@@ -869,9 +876,9 @@ private fun ObjectData.writeMsgpack(packer: MessagePacker) {
}
/**
- * Read ObjectData from MessageUnpacker
+ * Read WireObjectData from MessageUnpacker
*/
-private fun readObjectData(unpacker: MessageUnpacker): ObjectData {
+private fun readObjectData(unpacker: MessageUnpacker): WireObjectData {
val fieldCount = unpacker.unpackMapHeader()
var objectId: String? = null
var string: String? = null
@@ -905,5 +912,5 @@ private fun readObjectData(unpacker: MessageUnpacker): ObjectData {
}
}
- return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json)
+ return WireObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json)
}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveCounter.kt
new file mode 100644
index 000000000..bbeeb60ea
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveCounter.kt
@@ -0,0 +1,22 @@
+package io.ably.lib.liveobjects.value
+
+/**
+ * Default implementation of the [LiveCounter] value type - an immutable holder for
+ * the initial count of a LiveCounter object to be created. Mirrors ably-js
+ * `LiveCounterValueType`.
+ *
+ * Instantiated reflectively by [LiveCounter.create] through the constructor that
+ * takes the initial count; the count is retained internally with no public accessor
+ * (Spec: RTLCV3d).
+ *
+ * This is currently a skeleton: it only retains the initial value. Producing the
+ * `COUNTER_CREATE` operation/message from this count is not yet implemented.
+ *
+ * Spec: RTLCV1, RTLCV2, RTLCV3
+ */
+internal class DefaultLiveCounter(
+ internal val initialCount: Number,
+) : LiveCounter() {
+ // TODO - build the COUNTER_CREATE ObjectMessage from `initialCount`, mirroring
+ // ably-js LiveCounterValueType.createCounterCreateMessage. Spec: RTO12f
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveMap.kt
new file mode 100644
index 000000000..b0fcd9abb
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/DefaultLiveMap.kt
@@ -0,0 +1,24 @@
+package io.ably.lib.liveobjects.value
+
+/**
+ * Default implementation of the [LiveMap] value type - an immutable holder for the
+ * initial entries of a LiveMap object to be created. Mirrors ably-js
+ * `LiveMapValueType`.
+ *
+ * Instantiated reflectively by [LiveMap.create] through the constructor that takes
+ * the initial entries map; the entries are retained internally with no public
+ * accessor (Spec: RTLMV3d).
+ *
+ * This is currently a skeleton: it only retains the initial value. Producing the
+ * `MAP_CREATE` operation/message from these entries (including nested object create
+ * messages for nested [LiveMap]/[LiveCounter] value types) is not yet implemented.
+ *
+ * Spec: RTLMV1, RTLMV2, RTLMV3
+ */
+internal class DefaultLiveMap(
+ internal val entries: Map,
+) : LiveMap() {
+ // TODO - build the MAP_CREATE ObjectMessage (plus nested object create messages)
+ // from `entries`, mirroring ably-js LiveMapValueType.createMapCreateMessage.
+ // Spec: RTO11f
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/ResolvedValue.kt b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/ResolvedValue.kt
new file mode 100644
index 000000000..34002837d
--- /dev/null
+++ b/liveobjects/src/main/kotlin/io/ably/lib/liveobjects/value/ResolvedValue.kt
@@ -0,0 +1,39 @@
+package io.ably.lib.liveobjects.value
+
+import io.ably.lib.liveobjects.ValueType
+import io.ably.lib.liveobjects.message.WireObjectData
+
+/**
+ * The result of resolving a path segment / map entry against the objects
+ * graph: either a node view of a live object, or a primitive leaf carried as
+ * wire ObjectData.
+ */
+internal sealed interface ResolvedValue {
+ data class MapRef(val map: LiveMap) : ResolvedValue // TODO: LiveMap will be replaced by InternalLiveMap
+ data class CounterRef(val counter: LiveCounter) : ResolvedValue // TODO: LiveCounter will be replaced by InternalLiveCounter
+ data class Leaf(val data: WireObjectData) : ResolvedValue
+}
+
+/**
+ * Maps a resolved value to the public ValueType enum.
+ *
+ * Only ever invoked on a value that resolved to something - absence at a path is
+ * represented by a `null` [ResolvedValue] and surfaced as a `null` type by the
+ * caller, never as [ValueType.UNKNOWN]. UNKNOWN is reserved for a value that is
+ * present but matches none of the known categories.
+ *
+ * Spec: RTTS2a, RTTS4b3
+ */
+internal fun ResolvedValue.valueType(): ValueType = when (this) {
+ is ResolvedValue.MapRef -> ValueType.LIVE_MAP
+ is ResolvedValue.CounterRef -> ValueType.LIVE_COUNTER
+ is ResolvedValue.Leaf -> when {
+ data.string != null -> ValueType.STRING
+ data.number != null -> ValueType.NUMBER
+ data.boolean != null -> ValueType.BOOLEAN
+ data.bytes != null -> ValueType.BINARY
+ data.json?.isJsonObject == true -> ValueType.JSON_OBJECT
+ data.json?.isJsonArray == true -> ValueType.JSON_ARRAY
+ else -> ValueType.UNKNOWN
+ }
+}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt
deleted file mode 100644
index 786eb594b..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package io.ably.lib.objects
-
-import io.ably.lib.realtime.ChannelState
-import io.ably.lib.types.ProtocolMessage
-import java.util.concurrent.ConcurrentHashMap
-
-public class DefaultLiveObjectsPlugin(private val adapter: ObjectsAdapter) : LiveObjectsPlugin {
-
- private val objects = ConcurrentHashMap()
-
- override fun getInstance(channelName: String): RealtimeObjects {
- return objects.getOrPut(channelName) { DefaultRealtimeObjects(channelName, adapter) }
- }
-
- override fun handle(msg: ProtocolMessage) {
- val channelName = msg.channel
- objects[channelName]?.handle(msg)
- }
-
- override fun handleStateChange(channelName: String, state: ChannelState, hasObjects: Boolean) {
- objects[channelName]?.handleStateChange(state, hasObjects)
- }
-
- override fun dispose(channelName: String) {
- objects.remove(channelName)
- ?.dispose(clientError("Channel has been released using channels.release()"))
- }
-
- override fun dispose() {
- objects.values.forEach {
- it.dispose(clientError("AblyClient has been closed using client.close()"))
- }
- objects.clear()
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt
deleted file mode 100644
index 617388fb6..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt
+++ /dev/null
@@ -1,372 +0,0 @@
-package io.ably.lib.objects
-
-import io.ably.lib.objects.serialization.gson
-import io.ably.lib.objects.state.ObjectsStateChange
-import io.ably.lib.objects.state.ObjectsStateEvent
-import io.ably.lib.objects.type.ObjectType
-import io.ably.lib.objects.type.counter.LiveCounter
-import io.ably.lib.objects.type.livecounter.DefaultLiveCounter
-import io.ably.lib.objects.type.livemap.DefaultLiveMap
-import io.ably.lib.objects.type.map.LiveMap
-import io.ably.lib.objects.type.map.LiveMapValue
-import io.ably.lib.realtime.ChannelState
-import io.ably.lib.types.AblyException
-import io.ably.lib.types.ProtocolMessage
-import io.ably.lib.types.PublishResult
-import io.ably.lib.util.Clock
-import io.ably.lib.util.Log
-import io.ably.lib.util.SystemClock
-import kotlinx.coroutines.*
-import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
-import kotlinx.coroutines.flow.MutableSharedFlow
-import java.util.concurrent.CancellationException
-
-/**
- * Default implementation of RealtimeObjects interface.
- * Provides the core functionality for managing objects on a channel.
- */
-internal class DefaultRealtimeObjects(internal val channelName: String, internal val adapter: ObjectsAdapter): RealtimeObjects {
- private val tag = "DefaultRealtimeObjects"
- /**
- * @spec RTO3 - Objects pool storing all objects by object ID
- */
- internal val objectsPool = ObjectsPool(this)
-
- internal var state = ObjectsState.Initialized
-
- /**
- * Set of serials for operations applied locally upon ACK, awaiting deduplication of the server echo.
- * @spec RTO7b, RTO7b1
- */
- internal val appliedOnAckSerials = mutableSetOf()
-
- /**
- * @spec RTO4 - Used for handling object messages and object sync messages
- */
- private val objectsManager = ObjectsManager(this)
-
- /**
- * Coroutine scope for running sequential operations on a single thread, used to avoid concurrency issues.
- */
- private val sequentialScope =
- CoroutineScope(Dispatchers.Default.limitedParallelism(1) + CoroutineName(channelName) + SupervisorJob())
-
- /**
- * Event bus for handling incoming object messages sequentially.
- * Processes messages inside [incomingObjectsHandler] job created using [sequentialScope].
- */
- private val objectsEventBus = MutableSharedFlow(extraBufferCapacity = UNLIMITED)
- private val incomingObjectsHandler: Job
-
- /**
- * Provides a channel-specific scope for safely executing asynchronous operations with callbacks.
- */
- internal val asyncScope = ObjectsAsyncScope(channelName)
-
- init {
- incomingObjectsHandler = initializeHandlerForIncomingObjectMessages()
- }
-
- override fun getRoot(): LiveMap = runBlocking { getRootAsync() }
-
- override fun createMap(): LiveMap = createMap(mutableMapOf())
-
- override fun createMap(entries: MutableMap): LiveMap = runBlocking { createMapAsync(entries) }
-
- override fun createCounter(): LiveCounter = createCounter(0)
-
- override fun createCounter(initialValue: Number): LiveCounter = runBlocking { createCounterAsync(initialValue) }
-
- override fun getRootAsync(callback: ObjectsCallback) {
- asyncScope.launchWithCallback(callback) { getRootAsync() }
- }
-
- override fun createMapAsync(callback: ObjectsCallback) = createMapAsync(mutableMapOf(), callback)
-
- override fun createMapAsync(entries: MutableMap, callback: ObjectsCallback) {
- asyncScope.launchWithCallback(callback) { createMapAsync(entries) }
- }
-
- override fun createCounterAsync(callback: ObjectsCallback) = createCounterAsync(0, callback)
-
- override fun createCounterAsync(initialValue: Number, callback: ObjectsCallback) {
- asyncScope.launchWithCallback(callback) { createCounterAsync(initialValue) }
- }
-
- override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription =
- objectsManager.on(event, listener)
-
- override fun off(listener: ObjectsStateChange.Listener) = objectsManager.off(listener)
-
- override fun offAll() = objectsManager.offAll()
-
- private suspend fun getRootAsync(): LiveMap = withContext(sequentialScope.coroutineContext) {
- adapter.throwIfInvalidAccessApiConfiguration(channelName)
- adapter.ensureAttached(channelName)
- objectsManager.ensureSynced(state)
- objectsPool.get(ROOT_OBJECT_ID) as LiveMap
- }
-
- private suspend fun createMapAsync(entries: MutableMap): LiveMap {
- adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO11c, RTO11d, RTO11e
-
- if (entries.keys.any { it.isEmpty() }) { // RTO11f2
- throw invalidInputError("Map keys should not be empty")
- }
-
- // RTO11f14 - Create initial value operation
- val initialMapValue = DefaultLiveMap.initialValue(entries)
-
- // RTO11f15 - Create initial value JSON string
- val initialValueJSONString = gson.toJson(initialMapValue)
-
- // RTO11f8 - Create object ID from initial value
- val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Map, initialValueJSONString)
-
- // Create ObjectMessage with the operation
- val msg = ObjectMessage(
- operation = ObjectOperation(
- action = ObjectOperationAction.MapCreate,
- objectId = objectId,
- mapCreateWithObjectId = MapCreateWithObjectId(
- nonce = nonce,
- initialValue = initialValueJSONString,
- derivedFrom = initialMapValue,
- ),
- )
- )
-
- // RTO11i - publish and apply locally on ACK
- publishAndApply(arrayOf(msg))
-
- // RTO11h2 - Return existing object if found after apply
- return objectsPool.get(objectId) as? LiveMap
- ?: throw serverError("createMap: MAP_CREATE was not applied as expected; objectId=$objectId") // RTO11h3d
- }
-
- private suspend fun createCounterAsync(initialValue: Number): LiveCounter {
- adapter.throwIfInvalidWriteApiConfiguration(channelName) // RTO12c, RTO12d, RTO12e
-
- // Validate input parameter
- if (initialValue.toDouble().isNaN() || initialValue.toDouble().isInfinite()) {
- throw invalidInputError("Counter value should be a valid number")
- }
-
- // RTO12f12
- val initialCounterValue = DefaultLiveCounter.initialValue(initialValue)
- // RTO12f13 - Create initial value operation
- val initialValueJSONString = gson.toJson(initialCounterValue)
-
- // RTO12f6- Create object ID from initial value
- val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Counter, initialValueJSONString)
-
- // Create ObjectMessage with the operation
- val msg = ObjectMessage(
- operation = ObjectOperation(
- action = ObjectOperationAction.CounterCreate,
- objectId = objectId,
- counterCreateWithObjectId = CounterCreateWithObjectId(
- nonce = nonce,
- initialValue = initialValueJSONString,
- derivedFrom = initialCounterValue,
- ),
- )
- )
-
- // RTO12i - publish and apply locally on ACK
- publishAndApply(arrayOf(msg))
-
- // RTO12h2 - Return existing object if found after apply
- return objectsPool.get(objectId) as? LiveCounter
- ?: throw serverError("createCounter: COUNTER_CREATE was not applied as expected; objectId=$objectId") // RTO12h3d
- }
-
- /**
- * Spec: RTO11f8, RTO12f6
- */
- private suspend fun getObjectIdStringWithNonce(objectType: ObjectType, initialValue: String): Pair {
- val nonce = generateNonce()
- val msTimestamp = ServerTime.getCurrentTime(adapter) // RTO16 - Get server time for nonce generation
- return Pair(ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp).toString(), nonce)
- }
-
- /**
- * Spec: RTO15
- */
- internal suspend fun publish(objectMessages: Array): PublishResult {
- // 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
- return adapter.sendAsync(protocolMessage) // RTO15h
- }
-
- /**
- * Publishes the given object messages and, upon receiving the ACK, immediately applies them
- * locally as synthetic inbound messages using the assigned serial and connection's siteCode.
- *
- * Spec: RTO20
- */
- internal suspend fun publishAndApply(objectMessages: Array) {
- // RTO20b - publish, propagate failure
- val publishResult = publish(objectMessages)
-
- // RTO20c - validate required info
- val siteCode = adapter.connectionManager.siteCode
- if (siteCode == null) {
- Log.e(tag, "RTO20c1: siteCode not available; operations will be applied when echoed")
- return
- }
- val serials = publishResult.serials
- if (serials == null || serials.size != objectMessages.size) {
- Log.e(tag, "RTO20c2: PublishResult.serials unavailable or wrong length; operations will be applied when echoed")
- return
- }
-
- // RTO20d - create synthetic inbound ObjectMessages
- val syntheticMessages = mutableListOf()
- objectMessages.forEachIndexed { i, msg ->
- val serial = serials[i]
- if (serial == null) {
- Log.d(tag, "RTO20d1: serial null at index $i (conflated), skipping")
- return@forEachIndexed
- }
- syntheticMessages.add(msg.copy(serial = serial, siteCode = siteCode)) // RTO20d2a, RTO20d2b, RTO20d3
- }
- if (syntheticMessages.isEmpty()) return
-
- // RTO20e, RTO20f - dispatch to sequential scope for ordering
- withContext(sequentialScope.coroutineContext) {
- objectsManager.applyAckResult(syntheticMessages) // suspends if SYNCING (RTO20e), applies on SYNCED (RTO20f)
- }
- }
-
- /**
- * Handles a ProtocolMessage containing proto action as `object` or `object_sync`.
- * @spec RTL1 - Processes incoming object messages and object sync messages
- */
- internal fun handle(protocolMessage: ProtocolMessage) {
- // RTL15b - Set channel serial for OBJECT messages
- adapter.setChannelSerial(channelName, protocolMessage)
-
- if (protocolMessage.state == null || protocolMessage.state.isEmpty()) {
- Log.w(tag, "Received ProtocolMessage with null or empty objects, ignoring")
- return
- }
-
- objectsEventBus.tryEmit(protocolMessage)
- }
-
- /**
- * Initializes the handler for incoming object messages and object sync messages.
- * Processes the messages sequentially to ensure thread safety and correct order of operations.
- *
- * @spec OM2 - Populates missing fields from parent protocol message
- */
- private fun initializeHandlerForIncomingObjectMessages(): Job {
- return sequentialScope.launch {
- objectsEventBus.collect { protocolMessage ->
- // OM2 - Populate missing fields from parent
- val objects = protocolMessage.state.filterIsInstance()
- .mapIndexed { index, objMsg ->
- objMsg.copy(
- connectionId = objMsg.connectionId ?: protocolMessage.connectionId, // OM2c
- timestamp = objMsg.timestamp ?: protocolMessage.timestamp, // OM2e
- id = objMsg.id ?: (protocolMessage.id + ':' + index) // OM2a
- )
- }
-
- try {
- when (protocolMessage.action) {
- ProtocolMessage.Action.`object` -> objectsManager.handleObjectMessages(objects)
- ProtocolMessage.Action.object_sync -> objectsManager.handleObjectSyncMessages(
- objects,
- protocolMessage.channelSerial
- )
- else -> Log.w(tag, "Ignoring protocol message with unhandled action: ${protocolMessage.action}")
- }
- } catch (exception: Exception) {
- // Skip current message if an error occurs, don't rethrow to avoid crashing the collector
- Log.e(tag, "Error handling objects message with protocolMsg id ${protocolMessage.id}", exception)
- }
- }
- }
- }
-
- internal fun handleStateChange(state: ChannelState, hasObjects: Boolean) {
- sequentialScope.launch {
- when (state) {
- ChannelState.attached -> {
- Log.v(tag, "Objects.onAttached() channel=$channelName, hasObjects=$hasObjects")
-
- objectsManager.clearBufferedObjectOperations() // RTO4d - clear unconditionally on ATTACHED
-
- // RTO4a
- val fromInitializedState = this@DefaultRealtimeObjects.state == ObjectsState.Initialized
- if (hasObjects || fromInitializedState) {
- // should always start a new sync sequence if we're in the initialized state, no matter the HAS_OBJECTS flag value.
- // this guarantees we emit both "syncing" -> "synced" events in that order.
- objectsManager.startNewSync(null)
- }
-
- // RTO4b
- if (!hasObjects) {
- // if no HAS_OBJECTS flag received on attach, we can end sync sequence immediately and treat it as no objects on a channel.
- // reset the objects pool to its initial state, and emit update events so subscribers to root object get notified about changes.
- objectsPool.resetToInitialPool(true) // RTO4b1, RTO4b2
- objectsManager.clearSyncObjectsPool() // RTO4b3
- // RTO4b5 removed — buffer already cleared by RTO4d above
- // defer the state change event until the next tick if we started a new sequence just now due to being in initialized state.
- // this allows any event listeners to process the start of the new sequence event that was emitted earlier during this event loop.
- objectsManager.endSync() // RTO4b4
- }
- }
- ChannelState.detached,
- ChannelState.suspended,
- ChannelState.failed -> {
- val errorReason = try {
- adapter.getChannel(channelName).reason
- } catch (e: Exception) {
- null
- }
- val error = ablyException(
- "publishAndApply could not be applied locally: channel entered $state whilst waiting for objects sync",
- ErrorCode.PublishAndApplyFailedDueToChannelState,
- HttpStatusCode.BadRequest,
- cause = errorReason?.let { AblyException.fromErrorInfo(it) }
- )
- objectsManager.failBufferedAcks(error) // RTO20e1
- if (state != ChannelState.suspended) {
- // do not emit data update events as the actual current state of Objects data is unknown when we're in these channel states
- objectsPool.clearObjectsData(false)
- objectsManager.clearSyncObjectsPool()
- }
- }
- else -> {
- // No action needed for other states
- }
- }
- }
- }
-
- // Dispose of any resources associated with this RealtimeObjects instance
- fun dispose(cause: AblyException) {
- val disposeReason = CancellationException().apply { initCause(cause) }
- incomingObjectsHandler.cancel(disposeReason) // objectsEventBus automatically garbage collected when collector is cancelled
- objectsPool.dispose()
- objectsManager.dispose()
- // Don't cancel sequentialScope (needed in getRoot method), just cancel ongoing coroutines
- sequentialScope.coroutineContext.cancelChildren(disposeReason)
- asyncScope.cancel(disposeReason) // cancel all ongoing callbacks
- }
-}
-
-/**
- * Provides the default Clock instance for the DefaultRealtimeObjects.
- * This Clock is derived from the system clock, utilizing the client options
- * from the adapter configuration.
- */
-internal val DefaultRealtimeObjects.clock get(): Clock = SystemClock.clockFrom(adapter.clientOptions)
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt
deleted file mode 100644
index 1a8d1b8ad..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package io.ably.lib.objects
-
-internal enum class ErrorCode(public val code: Int) {
- BadRequest(40_000),
- InternalError(50_000),
- MaxMessageSizeExceeded(40_009),
- InvalidObject(92_000),
- // LiveMap specific error codes
- InvalidInputParams(40_003),
- MapValueDataTypeUnsupported(40_013),
- // Channel mode and state validation error codes
- ChannelModeRequired(40_024),
- ChannelStateError(90_001),
- PublishAndApplyFailedDueToChannelState(92_008),
-}
-
-internal enum class HttpStatusCode(public val code: Int) {
- BadRequest(400),
- InternalServerError(500),
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt
deleted file mode 100644
index 683971510..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt
+++ /dev/null
@@ -1,178 +0,0 @@
-package io.ably.lib.objects
-
-import io.ably.lib.realtime.ChannelState
-import io.ably.lib.realtime.CompletionListener
-import io.ably.lib.types.Callback
-import io.ably.lib.realtime.ConnectionEvent
-import io.ably.lib.realtime.ConnectionStateListener
-import io.ably.lib.types.ChannelMode
-import io.ably.lib.types.ErrorInfo
-import io.ably.lib.types.ProtocolMessage
-import io.ably.lib.types.PublishResult
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-
-internal val ObjectsAdapter.connectionManager get() = connection.connectionManager
-
-/**
- * Spec: RTO15g
- */
-internal suspend fun ObjectsAdapter.sendAsync(message: ProtocolMessage): PublishResult = suspendCancellableCoroutine { continuation ->
- try {
- connectionManager.send(message, clientOptions.queueMessages, object : Callback {
- override fun onSuccess(result: PublishResult) {
- continuation.resume(result)
- }
-
- override fun onError(reason: ErrorInfo) {
- continuation.resumeWithException(ablyException(reason))
- }
- })
- } catch (e: Exception) {
- continuation.resumeWithException(e)
- }
-}
-
-internal suspend fun ObjectsAdapter.attachAsync(channelName: String) = suspendCancellableCoroutine { continuation ->
- try {
- getChannel(channelName).attach(object : CompletionListener {
- override fun onSuccess() {
- continuation.resume(Unit)
- }
-
- override fun onError(reason: ErrorInfo) {
- continuation.resumeWithException(ablyException(reason))
- }
- })
- } catch (e: Exception) {
- continuation.resumeWithException(e)
- }
-}
-
-internal fun ObjectsAdapter.onGCGracePeriodUpdated(block : (Long?) -> Unit) : ObjectsSubscription {
- connectionManager.objectsGCGracePeriod?.let { block(it) }
- // Return new objectsGCGracePeriod whenever connection state changes to connected
- val listener: (_: ConnectionStateListener.ConnectionStateChange) -> Unit = {
- block(connectionManager.objectsGCGracePeriod)
- }
- connection.on(ConnectionEvent.connected, listener)
- return ObjectsSubscription { connection.off(listener) }
-}
-
-/**
- * Retrieves the channel modes for a specific channel.
- * This method returns the modes that are set for the specified channel.
- *
- * @param channelName the name of the channel for which to retrieve the modes
- * @return the array of channel modes for the specified channel, or null if the channel is not found
- * Spec: RTO2a, RTO2b
- */
-internal fun ObjectsAdapter.getChannelModes(channelName: String): Array? {
- val channel = getChannel(channelName)
-
- // RTO2a - channel.modes is only populated on channel attachment, so use it only if it is set
- channel.modes?.let { modes ->
- if (modes.isNotEmpty()) {
- return modes
- }
- }
-
- // RTO2b - otherwise as a best effort use user provided channel options
- channel.options?.let { options ->
- if (options.hasModes()) {
- return options.modes
- }
- }
- return null
-}
-
-/**
- * Spec: RTO15d
- */
-internal fun ObjectsAdapter.ensureMessageSizeWithinLimit(objectMessages: Array) {
- val maximumAllowedSize = connectionManager.maxMessageSize
- val objectsTotalMessageSize = objectMessages.sumOf { it.size() }
- if (objectsTotalMessageSize > maximumAllowedSize) {
- throw ablyException("ObjectMessages size $objectsTotalMessageSize exceeds maximum allowed size of $maximumAllowedSize bytes",
- ErrorCode.MaxMessageSizeExceeded)
- }
-}
-
-internal fun ObjectsAdapter.setChannelSerial(channelName: String, protocolMessage: ProtocolMessage) {
- if (protocolMessage.action != ProtocolMessage.Action.`object`) return
- val channelSerial = protocolMessage.channelSerial
- if (channelSerial.isNullOrEmpty()) return
- getChannel(channelName).properties.channelSerial = channelSerial
-}
-
-internal suspend fun ObjectsAdapter.ensureAttached(channelName: String) {
- val channel = getChannel(channelName)
- when (val currentChannelStatus = channel.state) {
- ChannelState.initialized -> attachAsync(channelName)
- ChannelState.attached -> return
- ChannelState.attaching -> {
- val attachDeferred = CompletableDeferred()
- getChannel(channelName).once {
- when(it.current) {
- ChannelState.attached -> attachDeferred.complete(Unit)
- else -> {
- val exception = ablyException("Channel $channelName is in invalid state: ${it.current}, " +
- "error: ${it.reason}", ErrorCode.ChannelStateError)
- attachDeferred.completeExceptionally(exception)
- }
- }
- }
- if (channel.state == ChannelState.attached) {
- attachDeferred.complete(Unit)
- }
- attachDeferred.await()
- }
- else ->
- throw ablyException("Channel $channelName is in invalid state: $currentChannelStatus", ErrorCode.ChannelStateError)
- }
-}
-
-// Spec: RTLO4b1, RTLO4b2
-internal fun ObjectsAdapter.throwIfInvalidAccessApiConfiguration(channelName: String) {
- throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed))
- throwIfMissingChannelMode(channelName, ChannelMode.object_subscribe)
-}
-
-internal fun ObjectsAdapter.throwIfInvalidWriteApiConfiguration(channelName: String) {
- throwIfEchoMessagesDisabled()
- throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed, ChannelState.suspended))
- throwIfMissingChannelMode(channelName, ChannelMode.object_publish)
-}
-
-internal fun ObjectsAdapter.throwIfUnpublishableState(channelName: String) {
- if (!connectionManager.isActive) {
- throw ablyException(connectionManager.stateErrorInfo)
- }
- throwIfInChannelState(channelName, arrayOf(ChannelState.failed, ChannelState.suspended))
-}
-
-// Spec: RTO2
-private fun ObjectsAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) {
- val channelModes = getChannelModes(channelName)
- if (channelModes == null || !channelModes.contains(channelMode)) {
- // Spec: RTO2a2, RTO2b2
- throw ablyException("\"${channelMode.name}\" channel mode must be set for this operation", ErrorCode.ChannelModeRequired)
- }
-}
-
-private fun ObjectsAdapter.throwIfInChannelState(channelName: String, channelStates: Array) {
- val currentState = getChannel(channelName).state
- if (currentState == null || channelStates.contains(currentState)) {
- throw ablyException("Channel is in invalid state: $currentState", ErrorCode.ChannelStateError)
- }
-}
-
-internal fun ObjectsAdapter.throwIfEchoMessagesDisabled() {
- if (!clientOptions.echoMessages) {
- throw clientError("\"echoMessages\" client option must be enabled for this operation")
- }
-}
-
-
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt
deleted file mode 100644
index 64a040ddc..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-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,
- private val hash: String,
- private val timestampMs: Long
-) {
- /**
- * Converts ObjectId to string representation.
- * Spec: RTO6b1
- */
- override fun toString(): String {
- return "${type.value}:$hash@$timestampMs"
- }
-
- companion object {
-
- /**
- * Spec: RTO14
- */
- internal fun fromInitialValue(objectType: ObjectType, initialValue: String, nonce: String, msTimeStamp: Long): ObjectId {
- val valueForHash = "$initialValue:$nonce".toByteArray(StandardCharsets.UTF_8)
- // RTO14b - Hash the initial value and nonce to create a unique identifier
- val hashBytes = MessageDigest.getInstance("SHA-256").digest(valueForHash)
- val urlSafeHash = Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes)
-
- return ObjectId(objectType, urlSafeHash, msTimeStamp)
- }
-
- /**
- * Creates ObjectId instance from hashed object id string.
- */
- internal fun fromString(objectId: String): ObjectId {
- if (objectId.isEmpty()) {
- throw objectError("Invalid object id: $objectId")
- }
-
- // Parse format: type:hash@msTimestamp
- val parts = objectId.split(':')
- if (parts.size != 2) {
- throw objectError("Invalid object id: $objectId")
- }
-
- val (typeStr, rest) = parts
-
- val type = when (typeStr) {
- "map" -> ObjectType.Map
- "counter" -> ObjectType.Counter
- else -> throw objectError("Invalid object type in object id: $objectId")
- }
-
- val hashAndTimestamp = rest.split('@')
- if (hashAndTimestamp.size != 2) {
- throw objectError("Invalid object id: $objectId")
- }
-
- val hash = hashAndTimestamp[0]
-
- if (hash.isEmpty()) {
- throw objectError("Invalid object id: $objectId")
- }
-
- val msTimestampStr = hashAndTimestamp[1]
-
- val msTimestamp = try {
- msTimestampStr.toLong()
- } catch (e: NumberFormatException) {
- throw objectError("Invalid object id: $objectId", e)
- }
-
- return ObjectId(type, hash, msTimestamp)
- }
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt
deleted file mode 100644
index 7f3e9b372..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt
+++ /dev/null
@@ -1,545 +0,0 @@
-package io.ably.lib.objects
-
-import com.google.gson.JsonElement
-import com.google.gson.JsonObject
-
-import com.google.gson.annotations.JsonAdapter
-import com.google.gson.annotations.SerializedName
-import io.ably.lib.objects.serialization.ObjectDataJsonSerializer
-import io.ably.lib.objects.serialization.gson
-import java.util.Base64
-
-/**
- * An enum class representing the different actions that can be performed on an object.
- * Spec: OOP2
- */
-internal enum class ObjectOperationAction(val code: Int) {
- MapCreate(0),
- MapSet(1),
- MapRemove(2),
- CounterCreate(3),
- CounterInc(4),
- ObjectDelete(5),
- MapClear(6),
- Unknown(-1); // code for unknown value during deserialization
-}
-
-/**
- * An enum class representing the conflict-resolution semantics used by a Map object.
- * Spec: OMP2
- */
-internal enum class ObjectsMapSemantics(val code: Int) {
- LWW(0),
- Unknown(-1); // code for unknown value during deserialization
-}
-
-/**
- * An ObjectData represents a value in an object on a channel.
- * Spec: OD1
- */
-@JsonAdapter(ObjectDataJsonSerializer::class)
-internal data class ObjectData(
- /**
- * A reference to another object, used to support composable object structures.
- * Spec: OD2a
- */
- val objectId: String? = null,
-
- /** String value. Spec: OD2c */
- val string: String? = null,
-
- /** Numeric value. Spec: OD2c */
- val number: Double? = null,
-
- /** Boolean value. Spec: OD2c */
- val boolean: Boolean? = null,
-
- /** Binary value encoded as a base64 string. Spec: OD2c */
- val bytes: String? = null,
-
- /** JSON object or array value. Spec: OD2c */
- val json: JsonElement? = null,
-)
-
-/**
- * Payload for MAP_CREATE operation.
- * Spec: MCR*
- */
-internal data class MapCreate(
- val semantics: ObjectsMapSemantics, // MCR2a
- val entries: Map // MCR2b
-)
-
-/**
- * Payload for MAP_SET operation.
- * Spec: MST*
- */
-internal data class MapSet(
- val key: String, // MST2a
- val value: ObjectData // MST2b - REQUIRED
-)
-
-/**
- * Payload for MAP_REMOVE operation.
- * Spec: MRM*
- */
-internal data class MapRemove(
- val key: String // MRM2a
-)
-
-/**
- * Payload for COUNTER_CREATE operation.
- * Spec: CCR*
- */
-internal data class CounterCreate(
- val count: Double // CCR2a - REQUIRED
-)
-
-/**
- * Payload for COUNTER_INC operation.
- * Spec: CIN*
- */
-internal data class CounterInc(
- val number: Double // CIN2a - REQUIRED
-)
-
-/**
- * Payload for OBJECT_DELETE operation.
- * Spec: ODE*
- * No fields - action is sufficient
- */
-internal object ObjectDelete
-
-/**
- * Payload for MAP_CLEAR operation.
- * Spec: MCL*
- * No fields - action is sufficient
- */
-internal object MapClear
-
-/**
- * Payload for MAP_CREATE_WITH_OBJECT_ID operation.
- * Spec: MCRO*
- */
-internal data class MapCreateWithObjectId(
- val initialValue: String, // MCRO2a
- val nonce: String, // MCRO2b
- @Transient val derivedFrom: MapCreate? = null,
-)
-
-/**
- * Payload for COUNTER_CREATE_WITH_OBJECT_ID operation.
- * Spec: CCRO*
- */
-internal data class CounterCreateWithObjectId(
- val initialValue: String, // CCRO2a
- val nonce: String, // CCRO2b
- @Transient val derivedFrom: CounterCreate? = null,
-)
-
-/**
- * A MapEntry represents the value at a given key in a Map object.
- * Spec: ME1
- */
-internal data class ObjectsMapEntry(
- /**
- * Indicates whether the map entry has been removed.
- * Spec: OME2a
- */
- val tombstone: Boolean? = null,
-
- /**
- * The serial value of the latest operation that was applied to the map entry.
- * It is optional in a MAP_CREATE operation and might be missing, in which case the client should use a null value for it
- * and treat it as the "earliest possible" serial for comparison purposes.
- * Spec: OME2b
- */
- val timeserial: String? = null,
-
- /**
- * A timestamp from the [timeserial] field. Only present if [tombstone] is `true`
- * Spec: OME2d
- */
- val serialTimestamp: Long? = null,
-
- /**
- * The data that represents the value of the map entry.
- * Spec: OME2c
- */
- val data: ObjectData? = null
-)
-
-/**
- * An ObjectMap object represents a map of key-value pairs.
- * Spec: OMP1
- */
-internal data class ObjectsMap(
- /**
- * The conflict-resolution semantics used by the map object.
- * Spec: OMP3a
- */
- val semantics: ObjectsMapSemantics? = null,
-
- /**
- * The map entries, indexed by key.
- * Spec: OMP3b
- */
- val entries: Map? = null,
-
- /**
- * The serial value of the last MAP_CLEAR operation applied to the map.
- * Spec: OMP3c
- */
- val clearTimeserial: String? = null,
-)
-
-/**
- * An ObjectCounter object represents an incrementable and decrementable value
- * Spec: OCN1
- */
-internal data class ObjectsCounter(
- /**
- * The value of the counter
- * Spec: OCN2a
- */
- val count: Double? = null
-)
-
-/**
- * An ObjectOperation describes an operation to be applied to an object on a channel.
- * Spec: OOP1
- */
-internal data class ObjectOperation(
- /**
- * Defines the operation to be applied to the object.
- * Spec: OOP3a
- */
- val action: ObjectOperationAction,
-
- /**
- * The object ID of the object on a channel to which the operation should be applied.
- * Spec: OOP3b
- */
- val objectId: String,
-
- /**
- * Payload for MAP_CREATE operation.
- * Spec: OOP3j
- */
- val mapCreate: MapCreate? = null,
-
- /**
- * Payload for MAP_SET operation.
- * Spec: OOP3k
- */
- val mapSet: MapSet? = null,
-
- /**
- * Payload for MAP_REMOVE operation.
- * Spec: OOP3l
- */
- val mapRemove: MapRemove? = null,
-
- /**
- * Payload for COUNTER_CREATE operation.
- * Spec: OOP3m
- */
- val counterCreate: CounterCreate? = null,
-
- /**
- * Payload for COUNTER_INC operation.
- * Spec: OOP3n
- */
- val counterInc: CounterInc? = null,
-
- /**
- * Payload for OBJECT_DELETE operation.
- * Spec: OOP3o
- */
- val objectDelete: ObjectDelete? = null,
-
- /**
- * Payload for MAP_CREATE_WITH_OBJECT_ID operation.
- * Spec: OOP3p
- */
- val mapCreateWithObjectId: MapCreateWithObjectId? = null,
-
- /**
- * Payload for COUNTER_CREATE_WITH_OBJECT_ID operation.
- * Spec: OOP3q
- */
- val counterCreateWithObjectId: CounterCreateWithObjectId? = null,
-
- /**
- * Payload for MAP_CLEAR operation.
- * Spec: OOP3r
- */
- val mapClear: MapClear? = null,
-)
-
-/**
- * An ObjectState describes the instantaneous state of an object on a channel.
- * Spec: OST1
- */
-internal data class ObjectState(
- /**
- * The identifier of the object.
- * Spec: OST2a
- */
- val objectId: String,
-
- /**
- * A map of serials keyed by a {@link ObjectMessage.siteCode},
- * representing the last operations applied to this object
- * Spec: OST2b
- */
- val siteTimeserials: Map,
-
- /**
- * True if the object has been tombstoned.
- * Spec: OST2c
- */
- val tombstone: Boolean,
-
- /**
- * The operation that created the object.
- * Can be missing if create operation for the object is not known at this point.
- * Spec: OST2d
- */
- val createOp: ObjectOperation? = null,
-
- /**
- * The data that represents the result of applying all operations to a Map object
- * excluding the initial value from the create operation if it is a Map object type.
- * Spec: OST2e
- */
- val map: ObjectsMap? = null,
-
- /**
- * The data that represents the result of applying all operations to a Counter object
- * excluding the initial value from the create operation if it is a Counter object type.
- * Spec: OST2f
- */
- val counter: ObjectsCounter? = null
-)
-
-/**
- * An @ObjectMessage@ represents an individual object message to be sent or received via the Ably Realtime service.
- * Spec: OM1
- */
-internal data class ObjectMessage(
- /**
- * unique ID for this object message. This attribute is always populated for object messages received over REST.
- * For object messages received over Realtime, if the object message does not contain an @id@,
- * it should be set to @protocolMsgId:index@, where @protocolMsgId@ is the id of the @ProtocolMessage@ encapsulating it,
- * and @index@ is the index of the object message inside the @state@ array of the @ProtocolMessage@
- * Spec: OM2a
- */
- val id: String? = null,
-
- /**
- * time in milliseconds since epoch. If an object message received from Ably does not contain a @timestamp@,
- * it should be set to the @timestamp@ of the encapsulating @ProtocolMessage@
- * Spec: OM2e
- */
- val timestamp: Long? = null,
-
- /**
- * Spec: OM2b
- */
- val clientId: String? = null,
-
- /**
- * If an object message received from Ably does not contain a @connectionId@,
- * it should be set to the @connectionId@ of the encapsulating @ProtocolMessage@
- * Spec: OM2c
- */
- val connectionId: String? = null,
-
- /**
- * JSON-encodable object, used to contain any arbitrary key value pairs which may also contain other primitive JSON types,
- * JSON-encodable objects or JSON-encodable arrays. The @extras@ field is provided to contain message metadata and/or
- * ancillary payloads in support of specific functionality. For 3.1 no specific functionality is specified for
- * @extras@ in object messages. Unless otherwise specified, the client library should not attempt to do any filtering
- * or validation of the @extras@ field itself, but should treat it opaquely, encoding it and passing it to realtime unaltered
- * Spec: OM2d
- */
- val extras: JsonObject? = null,
-
- /**
- * Describes an operation to be applied to an object.
- * Mutually exclusive with the `object` field. This field is only set on object messages if the `action` field of the
- * `ProtocolMessage` encapsulating it is `OBJECT`.
- * Spec: OM2f
- */
- val operation: ObjectOperation? = null,
-
- /**
- * Describes the instantaneous state of an object.
- * Mutually exclusive with the `operation` field. This field is only set on object messages if the `action` field of
- * the `ProtocolMessage` encapsulating it is `OBJECT_SYNC`.
- * Spec: OM2g
- */
- @SerializedName("object")
- val objectState: ObjectState? = null,
-
- /**
- * An opaque string that uniquely identifies this object message.
- * Spec: OM2h
- */
- val serial: String? = null,
-
- /**
- * A timestamp from the [serial] field.
- * Spec: OM2j
- */
- val serialTimestamp: Long? = null,
-
- /**
- * An opaque string used as a key to update the map of serial values on an object.
- * Spec: OM2i
- */
- val siteCode: String? = null
-)
-
-/**
- * Calculates the size of an ObjectMessage in bytes.
- * Spec: OM3
- */
-internal fun ObjectMessage.size(): Int {
- val clientIdSize = clientId?.byteSize ?: 0 // Spec: OM3f
- val operationSize = operation?.size() ?: 0 // Spec: OM3b, OOP4
- val objectStateSize = objectState?.size() ?: 0 // Spec: OM3c, OST3
- val extrasSize = extras?.let { gson.toJson(it).length } ?: 0 // Spec: OM3d
-
- return clientIdSize + operationSize + objectStateSize + extrasSize
-}
-
-/**
- * Calculates the size of an ObjectOperation in bytes.
- * Spec: OOP4
- */
-private fun ObjectOperation.size(): Int {
- val mapCreateSize = mapCreate?.size() ?: mapCreateWithObjectId?.derivedFrom?.size() ?: 0
- val mapSetSize = mapSet?.size() ?: 0
- val mapRemoveSize = mapRemove?.size() ?: 0
- val counterCreateSize = counterCreate?.size() ?: counterCreateWithObjectId?.derivedFrom?.size() ?: 0
- val counterIncSize = counterInc?.size() ?: 0
-
- return mapCreateSize + mapSetSize + mapRemoveSize +
- counterCreateSize + counterIncSize
-}
-
-/**
- * Calculates the size of an ObjectState in bytes.
- * Spec: OST3
- */
-private fun ObjectState.size(): Int {
- val mapSize = map?.size() ?: 0 // Spec: OST3b, OMP4
- val counterSize = counter?.size() ?: 0 // Spec: OST3c, OCN3
- val createOpSize = createOp?.size() ?: 0 // Spec: OST3d, OOP4
-
- return mapSize + counterSize + createOpSize
-}
-
-/**
- * Calculates the size of a MapCreate payload in bytes.
- */
-private fun MapCreate.size(): Int {
- return entries.entries.sumOf { it.key.byteSize + it.value.size() }
-}
-
-/**
- * Calculates the size of a MapSet payload in bytes.
- */
-private fun MapSet.size(): Int {
- return key.byteSize + value.size()
-}
-
-/**
- * Calculates the size of a MapRemove payload in bytes.
- */
-private fun MapRemove.size(): Int {
- return key.byteSize
-}
-
-/**
- * Calculates the size of a CounterCreate payload in bytes.
- */
-private fun CounterCreate.size(): Int {
- return 8 // Double is 8 bytes
-}
-
-/**
- * Calculates the size of a CounterInc payload in bytes.
- */
-private fun CounterInc.size(): Int {
- return 8 // Double is 8 bytes
-}
-
-/**
- * Calculates the size of a MapCreateWithObjectId payload in bytes.
- */
-private fun MapCreateWithObjectId.size(): Int {
- return initialValue.byteSize + nonce.byteSize
-}
-
-/**
- * Calculates the size of a CounterCreateWithObjectId payload in bytes.
- */
-private fun CounterCreateWithObjectId.size(): Int {
- return initialValue.byteSize + nonce.byteSize
-}
-
-/**
- * Calculates the size of an ObjectMap in bytes.
- * Spec: OMP4
- */
-private fun ObjectsMap.size(): Int {
- // Calculate the size of all map entries in the map property
- val entriesSize = entries?.entries?.sumOf {
- it.key.length + it.value.size() // // Spec: OMP4a1, OMP4a2
- } ?: 0
-
- return entriesSize
-}
-
-/**
- * Calculates the size of an ObjectCounter in bytes.
- * Spec: OCN3
- */
-private fun ObjectsCounter.size(): Int {
- // Size is 8 if count is a number, 0 if count is null or omitted
- return if (count != null) 8 else 0
-}
-
-/**
- * Calculates the size of a MapEntry in bytes.
- * Spec: OME3
- */
-private fun ObjectsMapEntry.size(): Int {
- // The size is equal to the size of the data property, calculated per "OD3"
- return data?.size() ?: 0
-}
-
-/**
- * Calculates the size of an ObjectData in bytes.
- * Spec: OD3
- */
-private fun ObjectData.size(): Int {
- string?.let { return it.byteSize } // Spec: OD3e
- number?.let { return 8 } // Spec: OD3d
- boolean?.let { return 1 } // Spec: OD3b
- bytes?.let { return Base64.getDecoder().decode(it).size } // Spec: OD3c
- json?.let { return it.toString().byteSize } // Spec: OD3e
- return 0
-}
-
-internal fun ObjectData?.isInvalid(): Boolean {
- return this?.objectId.isNullOrEmpty() &&
- this?.string == null &&
- this?.number == null &&
- this?.boolean == null &&
- this?.bytes == null &&
- this?.json == null
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt
deleted file mode 100644
index 9c669f033..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt
+++ /dev/null
@@ -1,328 +0,0 @@
-package io.ably.lib.objects
-
-import io.ably.lib.objects.type.BaseRealtimeObject
-import io.ably.lib.objects.type.ObjectUpdate
-import io.ably.lib.objects.type.livecounter.DefaultLiveCounter
-import io.ably.lib.objects.type.livemap.DefaultLiveMap
-import io.ably.lib.types.AblyException
-import io.ably.lib.util.Log
-import kotlinx.coroutines.CompletableDeferred
-
-/**
- * @spec RTO5 - Processes OBJECT and OBJECT_SYNC messages during sync sequences
- * @spec RTO6 - Creates zero-value objects when needed
- */
-internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObjects): ObjectsStateCoordinator() {
- private val tag = "ObjectsManager"
- /**
- * @spec RTO5 - Sync objects pool for collecting sync messages
- */
- private val syncObjectsPool = mutableMapOf()
- private var currentSyncId: String? = null
- /**
- * @spec RTO7 - Buffered object operations during sync
- */
- private val bufferedObjectOperations = mutableListOf() // RTO7a
- private var syncCompletionWaiter: CompletableDeferred? = null
-
- /**
- * Handles object messages (non-sync messages).
- *
- * @spec RTO8 - Buffers messages if not synced, applies immediately if synced
- */
- internal fun handleObjectMessages(objectMessages: List) {
- if (realtimeObjects.state != ObjectsState.Synced) {
- // RTO7 - The client receives object messages in realtime over the channel concurrently with the sync sequence.
- // Some of the incoming object messages may have already been applied to the objects described in
- // the sync sequence, but others may not; therefore we must buffer these messages so that we can apply
- // them to the objects once the sync is complete.
- Log.v(tag, "Buffering ${objectMessages.size} object messages, state: ${realtimeObjects.state}")
- bufferedObjectOperations.addAll(objectMessages) // RTO8a
- return
- }
-
- // Apply messages immediately if synced
- applyObjectMessages(objectMessages, ObjectsOperationSource.CHANNEL) // RTO8b
- }
-
- /**
- * Handles object sync messages.
- *
- * @spec RTO5 - Parses sync channel serial and manages sync sequences
- */
- internal fun handleObjectSyncMessages(objectMessages: List, syncChannelSerial: String?) {
- val syncTracker = ObjectsSyncTracker(syncChannelSerial)
- val isNewSync = syncTracker.hasSyncStarted(currentSyncId)
- if (isNewSync) {
- // RTO5a2 - new sync sequence started
- startNewSync(syncTracker.syncId)
- }
-
- // RTO5a3 - continue current sync sequence
- applyObjectSyncMessages(objectMessages) // RTO5f
-
- // RTO5a4 - if this is the last (or only) message in a sequence of sync updates, end the sync
- if (syncTracker.hasSyncEnded()) {
- // defer the state change event until the next tick if this was a new sync sequence
- // to allow any event listeners to process the start of the new sequence event that was emitted earlier during this event loop.
- endSync()
- }
- }
-
- /**
- * Starts a new sync sequence.
- *
- * @spec RTO5 - Sync sequence initialization
- */
- internal fun startNewSync(syncId: String?) {
- Log.v(tag, "Starting new sync sequence: syncId=$syncId")
-
- syncObjectsPool.clear() // RTO5a2a
- currentSyncId = syncId
- syncCompletionWaiter = CompletableDeferred()
- stateChange(ObjectsState.Syncing)
- }
-
- /**
- * Ends the current sync sequence.
- *
- * @spec RTO5c - Applies sync data and buffered operations
- */
- internal fun endSync() {
- Log.v(tag, "Ending sync sequence")
- applySync() // RTO5c1/2/7
- applyObjectMessages(bufferedObjectOperations, ObjectsOperationSource.CHANNEL) // RTO5c6
- bufferedObjectOperations.clear() // RTO5c5
- syncObjectsPool.clear() // RTO5c4
- currentSyncId = null // RTO5c3
- realtimeObjects.appliedOnAckSerials.clear() // RTO5c9
- stateChange(ObjectsState.Synced) // RTO5c8
- syncCompletionWaiter?.complete(Unit)
- syncCompletionWaiter = null
- }
-
- /**
- * Called from publishAndApply (via withContext sequentialScope).
- * If SYNCED: apply immediately with LOCAL source.
- * If not SYNCED: suspend until endSync transitions to SYNCED (RTO20e), then apply.
- */
- internal suspend fun applyAckResult(messages: List) {
- if (realtimeObjects.state != ObjectsState.Synced) {
- if (syncCompletionWaiter == null) syncCompletionWaiter = CompletableDeferred()
- syncCompletionWaiter?.await() // suspends; resumes after endSync transitions to SYNCED (RTO20e1)
- }
- applyObjectMessages(messages, ObjectsOperationSource.LOCAL) // RTO20f
- }
-
- /**
- * Fails all pending apply waiters.
- * Called when the channel enters DETACHED/SUSPENDED/FAILED (RTO20e1).
- */
- internal fun failBufferedAcks(error: AblyException) {
- syncCompletionWaiter?.completeExceptionally(error)
- syncCompletionWaiter = null
- }
-
- /**
- * Clears the sync objects pool.
- * Used by DefaultRealtimeObjects.handleStateChange.
- */
- internal fun clearSyncObjectsPool() {
- syncObjectsPool.clear()
- }
-
- /**
- * Clears the buffered object operations.
- * Used by DefaultRealtimeObjects.handleStateChange.
- */
- internal fun clearBufferedObjectOperations() {
- bufferedObjectOperations.clear()
- }
-
- /**
- * Applies sync data to objects pool.
- *
- * @spec RTO5c - Processes sync data and updates objects pool
- */
- private fun applySync() {
- if (syncObjectsPool.isEmpty()) {
- return
- }
-
- val receivedObjectIds = mutableSetOf()
- // RTO5c1a2 - List to collect updates for existing objects
- val existingObjectUpdates = mutableListOf>()
-
- // RTO5c1
- for ((objectId, objectMessage) in syncObjectsPool) {
- val objectState = objectMessage.objectState as ObjectState // we have non-null objectState here due to RTO5f
- receivedObjectIds.add(objectId)
- val existingObject = realtimeObjects.objectsPool.get(objectId)
-
- // RTO5c1a
- if (existingObject != null) {
- // Update existing object
- val update = existingObject.applyObjectSync(objectMessage) // RTO5c1a1
- existingObjectUpdates.add(Pair(existingObject, update))
- } else { // RTO5c1b
- // RTO5c1b1, RTO5c1b1a, RTO5c1b1b - Create new object and add it to the pool
- val newObject = createObjectFromState(objectState) ?: continue // RTO5c1b1c - skip unsupported
- newObject.applyObjectSync(objectMessage)
- realtimeObjects.objectsPool.set(objectId, newObject)
- }
- }
-
- // RTO5c2 - need to remove realtimeObject instances from the ObjectsPool for which objectIds were not received during the sync sequence
- realtimeObjects.objectsPool.deleteExtraObjectIds(receivedObjectIds)
-
- // RTO5c7 - call subscription callbacks for all updated existing objects
- existingObjectUpdates.forEach { (obj, update) ->
- obj.notifyUpdated(update)
- }
- }
-
- /**
- * Applies object messages to objects.
- *
- * @spec RTO9 - Creates zero-value objects if they don't exist
- */
- private fun applyObjectMessages(
- objectMessages: List,
- source: ObjectsOperationSource = ObjectsOperationSource.CHANNEL,
- ) {
- // RTO9a
- for (objectMessage in objectMessages) {
- if (objectMessage.operation == null) {
- // RTO9a1
- Log.w(tag, "Object message received without operation field, skipping message: ${objectMessage.id}")
- continue
- }
-
- val objectOperation: ObjectOperation = objectMessage.operation // RTO9a2
- if (objectOperation.action == ObjectOperationAction.Unknown) {
- // RTO9a2b - object operation action is unknown, skip the message
- Log.w(tag, "Object operation action is unknown, skipping message: ${objectMessage.id}")
- continue
- }
-
- // RTO9a3 - skip operations already applied on ACK (discard without taking any further action).
- // This check comes before zero-value object creation (RTO9a2a1) so that no zero-value object is
- // created for an objectId not yet in the pool when the echo is being discarded.
- // Note: siteTimeserials is NOT updated here intentionally — updating it to the echo's serial would
- // incorrectly reject older-but-unprocessed operations from the same site that arrive after the echo.
- if (objectMessage.serial != null &&
- realtimeObjects.appliedOnAckSerials.contains(objectMessage.serial)) {
- Log.d(tag, "RTO9a3: serial ${objectMessage.serial} already applied on ACK; discarding echo")
- realtimeObjects.appliedOnAckSerials.remove(objectMessage.serial)
- continue // discard without taking any further action
- }
-
- // RTO9a2a - we can receive an op for an object id we don't have yet in the pool. instead of buffering such operations,
- // we can create a zero-value object for the provided object id and apply the operation to that zero-value object.
- // this also means that all objects are capable of applying the corresponding *_CREATE ops on themselves,
- // since they need to be able to eventually initialize themselves from that *_CREATE op.
- // so to simplify operations handling, we always try to create a zero-value object in the pool first,
- // and then we can always apply the operation on the existing object in the pool.
- val obj = realtimeObjects.objectsPool.createZeroValueObjectIfNotExists(objectOperation.objectId) // RTO9a2a1
- val applied = obj.applyObject(objectMessage, source) // RTO9a2a2, RTO9a2a3
- if (source == ObjectsOperationSource.LOCAL && applied && objectMessage.serial != null) {
- realtimeObjects.appliedOnAckSerials.add(objectMessage.serial) // RTO9a2a4
- }
- }
- }
-
- /**
- * Applies sync messages to sync data pool, merging partial sync messages for the same objectId.
- *
- * @spec RTO5f - Collects and merges object states during sync sequence
- */
- private fun applyObjectSyncMessages(objectMessages: List) {
- for (objectMessage in objectMessages) {
- if (objectMessage.objectState == null) {
- Log.w(tag, "Object message received during OBJECT_SYNC without object field, skipping message: ${objectMessage.id}")
- continue
- }
-
- val objectState: ObjectState = objectMessage.objectState
- val objectId = objectState.objectId
- val existingEntry = syncObjectsPool[objectId]
-
- if (existingEntry == null) {
- // RTO5f1 - objectId not in pool, store directly
- if (objectState.counter != null || objectState.map != null) {
- syncObjectsPool[objectId] = objectMessage
- } else {
- // RTO5c1b1c - object state must contain either counter or map data
- Log.w(tag, "Object state received without counter or map data, skipping message: ${objectMessage.id}")
- }
- continue
- }
-
- // RTO5f2 - objectId already in pool; this is a partial sync message, merge based on type
- when {
- objectState.map != null -> {
- // RTO5f2a - map object: merge entries
- if (objectState.tombstone) {
- // RTO5f2a1 - tombstone: replace pool entry entirely
- syncObjectsPool[objectId] = objectMessage
- } else {
- // RTO5f2a2 - merge map entries; server guarantees no duplicate keys across partials
- val existingState = existingEntry.objectState!! // non-null for existing entry
- val mergedEntries = existingState.map?.entries.orEmpty() + objectState.map.entries.orEmpty()
- val mergedMap = (existingState.map ?: ObjectsMap()).copy(entries = mergedEntries)
- val mergedState = existingState.copy(map = mergedMap)
- syncObjectsPool[objectId] = existingEntry.copy(objectState = mergedState)
- }
- }
- objectState.counter != null -> {
- // RTO5f2b - counter objects must never be split across messages
- Log.e(tag, "Received partial sync message for a counter object, skipping: ${objectMessage.id}")
- }
- else -> {
- // RTO5f2c - unsupported type, log warning and skip
- Log.w(tag, "Received partial sync message for an unsupported object type, skipping: ${objectMessage.id}")
- }
- }
- }
- }
-
- /**
- * Creates an object from object state.
- *
- * @spec RTO5c1b - Creates objects from object state based on type
- */
- private fun createObjectFromState(objectState: ObjectState): BaseRealtimeObject? {
- return when {
- objectState.counter != null -> DefaultLiveCounter.zeroValue(objectState.objectId, realtimeObjects) // RTO5c1b1a
- objectState.map != null -> DefaultLiveMap.zeroValue(objectState.objectId, realtimeObjects) // RTO5c1b1b
- else -> {
- // RTO5c1b1c - unsupported object type, skip gracefully
- Log.w(tag, "Received unsupported object state during OBJECT_SYNC (no counter or map), skipping objectId: ${objectState.objectId}")
- null
- }
- }
- }
-
- /**
- * Changes the state and emits events.
- *
- * @spec RTO2 - Emits state change events for syncing and synced states
- */
- private fun stateChange(newState: ObjectsState) {
- if (realtimeObjects.state == newState) {
- return
- }
- Log.v(tag, "Objects state changed to: $newState from ${realtimeObjects.state}")
- realtimeObjects.state = newState
-
- // deferEvent not needed since objectsStateChanged processes events in a sequential coroutine scope
- objectsStateChanged(newState)
- }
-
- internal fun dispose() {
- syncCompletionWaiter?.cancel()
- syncObjectsPool.clear()
- bufferedObjectOperations.clear()
- disposeObjectsStateListeners()
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt
deleted file mode 100644
index e850d31b8..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package io.ably.lib.objects
-
-/** @spec RTO22 */
-internal enum class ObjectsOperationSource {
- LOCAL, // RTO22a - applied upon receipt of ACK
- CHANNEL // RTO22b - received over a Realtime channel
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt
deleted file mode 100644
index 224cd606f..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt
+++ /dev/null
@@ -1,172 +0,0 @@
-package io.ably.lib.objects
-
-import io.ably.lib.objects.type.BaseRealtimeObject
-import io.ably.lib.objects.type.ObjectType
-import io.ably.lib.objects.type.livecounter.DefaultLiveCounter
-import io.ably.lib.objects.type.livemap.DefaultLiveMap
-import io.ably.lib.util.Log
-import kotlinx.coroutines.*
-import java.util.concurrent.ConcurrentHashMap
-
-/**
- * Constants for ObjectsPool configuration
- */
-internal object ObjectsPoolDefaults {
- const val GC_INTERVAL_MS = 1000L * 60 * 5 // 5 minutes
- /**
- * The SDK will attempt to use the `objectsGCGracePeriod` value provided by the server in the `connectionDetails`
- * object of the `CONNECTED` event.
- * If the server does not provide this value, the SDK will fall back to this default value.
- * Must be > 2 minutes to ensure we keep tombstones long enough to avoid the possibility of receiving an operation
- * with an earlier serial that would not have been applied if the tombstone still existed.
- *
- * Applies both for map entries tombstones and object tombstones.
- */
- const val GC_GRACE_PERIOD_MS = 1000L * 60 * 60 * 24 // 24 hours
-}
-
-/**
- * Root object ID constant
- */
-internal const val ROOT_OBJECT_ID = "root"
-
-/**
- * ObjectsPool manages a pool of objects for a channel.
- *
- * @spec RTO3 - Maintains an objects pool for all objects on the channel
- */
-internal class ObjectsPool(
- private val realtimeObjects: DefaultRealtimeObjects
-) {
- private val tag = "ObjectsPool"
-
- /**
- * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveCounter.
- * @spec RTO3a - Pool storing all ably objects by object ID
- */
- private val pool = ConcurrentHashMap()
-
- /**
- * Coroutine scope for garbage collection
- */
- private val gcScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
- private var gcJob: Job // Job for the garbage collection coroutine
-
- @Volatile private var gcGracePeriod = ObjectsPoolDefaults.GC_GRACE_PERIOD_MS
- private var gcPeriodSubscription: ObjectsSubscription
-
- init {
- // RTO3b - Initialize pool with root object
- pool[ROOT_OBJECT_ID] = DefaultLiveMap.zeroValue(ROOT_OBJECT_ID, realtimeObjects)
- // Start garbage collection coroutine with server-provided grace period if available
- gcPeriodSubscription = realtimeObjects.adapter.onGCGracePeriodUpdated { period ->
- period?.let {
- gcGracePeriod = it
- Log.i(tag, "Using objectsGCGracePeriod from server: $gcGracePeriod ms")
- } ?: Log.i(tag, "Server did not provide objectsGCGracePeriod, using default: $gcGracePeriod ms")
- }
- gcJob = startGCJob()
- }
-
- /**
- * Gets an object from the pool by object ID.
- */
- internal fun get(objectId: String): BaseRealtimeObject? {
- return pool[objectId]
- }
-
- /**
- * Sets a realtime object in the pool.
- */
- internal fun set(objectId: String, realtimeObject: BaseRealtimeObject) {
- pool[objectId] = realtimeObject
- }
-
- /**
- * Removes all objects but root from the pool and clears the data for root.
- * Does not create a new root object, so the reference to the root object remains the same.
- */
- internal fun resetToInitialPool(emitUpdateEvents: Boolean) {
- pool.entries.removeIf { (key, _) -> key != ROOT_OBJECT_ID } // only keep the root object
- clearObjectsData(emitUpdateEvents) // RTO4b2a - clear the root object and emit update events
- }
-
-
- /**
- * Deletes objects from the pool for which object ids are not found in the provided array of ids.
- * Spec: RTO5c2
- */
- internal fun deleteExtraObjectIds(objectIds: MutableSet) {
- pool.entries.removeIf { (key, _) -> key !in objectIds && key != ROOT_OBJECT_ID } // RTO5c2a - Keep root object
- }
-
- /**
- * Clears the data stored for all objects in the pool.
- */
- internal fun clearObjectsData(emitUpdateEvents: Boolean) {
- for (obj in pool.values) {
- val update = obj.clearData()
- if (emitUpdateEvents) obj.notifyUpdated(update)
- }
- }
-
- /**
- * Creates a zero-value object if it doesn't exist in the pool.
- *
- * @spec RTO6 - Creates zero-value objects when needed
- */
- internal fun createZeroValueObjectIfNotExists(objectId: String): BaseRealtimeObject {
- val existingObject = get(objectId)
- if (existingObject != null) {
- return existingObject // RTO6a
- }
-
- val parsedObjectId = ObjectId.fromString(objectId) // RTO6b
- return when (parsedObjectId.type) {
- ObjectType.Map -> DefaultLiveMap.zeroValue(objectId, realtimeObjects) // RTO6b2
- ObjectType.Counter -> DefaultLiveCounter.zeroValue(objectId, realtimeObjects) // RTO6b3
- }.apply {
- set(objectId, this) // RTO6b4 - Add the zero-value object to the pool
- }
- }
-
- /**
- * Garbage collection interval handler.
- */
- private fun onGCInterval() {
- pool.entries.removeIf { (_, obj) ->
- if (obj.isEligibleForGc(gcGracePeriod)) { true } // Remove from pool
- else {
- obj.onGCInterval(gcGracePeriod)
- false // Keep in pool
- }
- }
- }
-
- /**
- * Starts the garbage collection coroutine.
- */
- private fun startGCJob() : Job {
- return gcScope.launch {
- while (isActive) {
- try {
- onGCInterval()
- } catch (e: Exception) {
- Log.e(tag, "Error during garbage collection", e)
- }
- delay(ObjectsPoolDefaults.GC_INTERVAL_MS)
- }
- }
- }
-
- /**
- * Disposes of the ObjectsPool, cleaning up resources.
- * Should be called when the pool is no longer needed.
- */
- fun dispose() {
- gcPeriodSubscription.unsubscribe()
- gcJob.cancel()
- gcScope.cancel()
- pool.clear()
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt
deleted file mode 100644
index cdd742ec0..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt
+++ /dev/null
@@ -1,108 +0,0 @@
-package io.ably.lib.objects
-
-import io.ably.lib.objects.state.ObjectsStateChange
-import io.ably.lib.objects.state.ObjectsStateEvent
-import io.ably.lib.util.EventEmitter
-import io.ably.lib.util.Log
-import kotlinx.coroutines.*
-
-/**
- * @spec RTO2 - enum representing objects state
- */
-internal enum class ObjectsState {
- Initialized,
- Syncing,
- Synced
-}
-
-/**
- * Maps internal ObjectsState values to their corresponding public ObjectsStateEvent values.
- * Used to determine which events should be emitted when state changes occur.
- * INITIALIZED maps to null (no event), while SYNCING and SYNCED map to their respective events.
- */
-private val objectsStateToEventMap = mapOf(
- ObjectsState.Initialized to null,
- ObjectsState.Syncing to ObjectsStateEvent.SYNCING,
- ObjectsState.Synced to ObjectsStateEvent.SYNCED
-)
-
-/**
- * An interface for managing and communicating changes in the synchronization state of objects.
- *
- * Implementations should ensure thread-safe event emission and proper synchronization
- * between state change notifications.
- */
-internal interface HandlesObjectsStateChange {
- /**
- * Handles changes in the state of objects by notifying all registered listeners.
- * Implementations should ensure thread-safe event emission to both internal and public listeners.
- * Makes sure every event is processed in the order they were received.
- * @param newState The new state of the objects, SYNCING or SYNCED.
- */
- fun objectsStateChanged(newState: ObjectsState)
-
- /**
- * Suspends the current coroutine until objects are synchronized.
- * Returns immediately if state is already SYNCED, otherwise waits for the SYNCED event.
- *
- * @param currentState The current state of objects to determine if waiting is necessary
- */
- suspend fun ensureSynced(currentState: ObjectsState)
-
- /**
- * Disposes all registered state change listeners and cancels any pending operations.
- * Should be called when the associated RealtimeObjects instance is no longer needed.
- */
- fun disposeObjectsStateListeners()
-}
-
-
-internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObjectsStateChange {
- private val tag = "ObjectsStateCoordinator"
- private val internalObjectStateEmitter = ObjectsStateEmitter()
- // related to RTC10, should have a separate EventEmitter for users of the library
- private val externalObjectStateEmitter = ObjectsStateEmitter()
-
- override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription {
- externalObjectStateEmitter.on(event, listener)
- return ObjectsSubscription {
- externalObjectStateEmitter.off(event, listener)
- }
- }
-
- override fun off(listener: ObjectsStateChange.Listener) = externalObjectStateEmitter.off(listener)
-
- override fun offAll() = externalObjectStateEmitter.off()
-
- override fun objectsStateChanged(newState: ObjectsState) {
- objectsStateToEventMap[newState]?.let { objectsStateEvent ->
- internalObjectStateEmitter.emit(objectsStateEvent)
- externalObjectStateEmitter.emit(objectsStateEvent)
- }
- }
-
- override suspend fun ensureSynced(currentState: ObjectsState) {
- if (currentState != ObjectsState.Synced) {
- val deferred = CompletableDeferred()
- internalObjectStateEmitter.once(ObjectsStateEvent.SYNCED) {
- Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced")
- deferred.complete(Unit)
- }
- deferred.await()
- }
- }
-
- override fun disposeObjectsStateListeners() = offAll()
-}
-
-private class ObjectsStateEmitter : EventEmitter() {
- private val tag = "ObjectsStateEmitter"
- override fun apply(listener: ObjectsStateChange.Listener?, event: ObjectsStateEvent?, vararg args: Any?) {
- try {
- event?.let { listener?.onStateChanged(it) }
- ?: Log.w(tag, "Null event passed to ObjectsStateChange Listener callback")
- } catch (t: Throwable) {
- Log.e(tag, "Error occurred while executing listener callback for event: $event", t)
- }
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt
deleted file mode 100644
index 5c2a193d5..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package io.ably.lib.objects
-
-/**
- * @spec RTO5 - SyncTracker class for tracking objects sync status
- */
-internal class ObjectsSyncTracker(syncChannelSerial: String?) {
- private val syncSerial: String? = syncChannelSerial
- internal val syncId: String?
- internal val syncCursor: String?
-
- init {
- val parsed = parseSyncChannelSerial(syncChannelSerial)
- syncId = parsed.first
- syncCursor = parsed.second
- }
-
- /**
- * Checks if a new sync sequence has started.
- *
- * @param prevSyncId The previously stored sync ID
- * @return true if a new sync sequence has started, false otherwise
- *
- * Spec: RTO5a5, RTO5a2
- */
- internal fun hasSyncStarted(prevSyncId: String?): Boolean {
- return syncSerial.isNullOrEmpty() || prevSyncId != syncId
- }
-
- /**
- * Checks if the current sync sequence has ended.
- *
- * @return true if the sync sequence has ended, false otherwise
- *
- * Spec: RTO5a5, RTO5a4
- */
- internal fun hasSyncEnded(): Boolean {
- return syncSerial.isNullOrEmpty() || syncCursor.isNullOrEmpty()
- }
-
- companion object {
- /**
- * Parses sync channel serial to extract syncId and syncCursor.
- *
- * @param syncChannelSerial The sync channel serial to parse
- * @return Pair of syncId and syncCursor, both null if parsing fails
- */
- private fun parseSyncChannelSerial(syncChannelSerial: String?): Pair {
- if (syncChannelSerial.isNullOrEmpty()) {
- return Pair(null, null)
- }
-
- // RTO5a1 - syncChannelSerial is a two-part identifier: :
- val match = Regex("^([\\w-]+):(.*)$").find(syncChannelSerial)
- return if (match != null) {
- val syncId = match.groupValues[1]
- val syncCursor = match.groupValues[2]
- Pair(syncId, syncCursor)
- } else {
- Pair(null, null)
- }
- }
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt
deleted file mode 100644
index 09b8b1c14..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package io.ably.lib.objects
-
-import io.ably.lib.types.AblyException
-import io.ably.lib.util.SystemClock
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import kotlin.concurrent.Volatile
-
-/**
- * ServerTime is a utility object that provides the current server time
- * Spec: RTO16
- */
-internal object ServerTime {
- @Volatile
- private var serverTimeOffset: Long? = null
- private val mutex = Mutex()
-
- /**
- * Spec: RTO16a
- */
- @Throws(AblyException::class)
- internal suspend fun getCurrentTime(adapter: ObjectsAdapter): Long {
- val clock = SystemClock.clockFrom(adapter.clientOptions)
- 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 - clock.currentTimeMillis()
- return serverTime
- }
- }
- }
- return clock.currentTimeMillis() + serverTimeOffset!!
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt
deleted file mode 100644
index 3e136163e..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package io.ably.lib.objects
-
-import io.ably.lib.types.AblyException
-import io.ably.lib.types.ErrorInfo
-import io.ably.lib.util.Log
-import kotlinx.coroutines.*
-import java.nio.charset.StandardCharsets
-import java.util.concurrent.CancellationException
-
-internal fun ablyException(
- errorMessage: String,
- errorCode: ErrorCode,
- statusCode: HttpStatusCode = HttpStatusCode.BadRequest,
- cause: Throwable? = null,
-): AblyException {
- val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode)
- return createAblyException(errorInfo, cause)
-}
-
-internal fun ablyException(
- errorInfo: ErrorInfo,
- cause: Throwable? = null,
-): AblyException = createAblyException(errorInfo, cause)
-
-private fun createErrorInfo(
- errorMessage: String,
- errorCode: ErrorCode,
- statusCode: HttpStatusCode,
-) = ErrorInfo(errorMessage, statusCode.code, errorCode.code)
-
-private fun createAblyException(
- errorInfo: ErrorInfo,
- cause: Throwable?,
-) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) }
- ?: AblyException.fromErrorInfo(errorInfo)
-
-internal fun clientError(errorMessage: String) = ablyException(errorMessage, ErrorCode.BadRequest, HttpStatusCode.BadRequest)
-
-internal fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCode.InternalError, HttpStatusCode.InternalServerError)
-
-internal fun objectError(errorMessage: String, cause: Throwable? = null): AblyException {
- return ablyException(errorMessage, ErrorCode.InvalidObject, HttpStatusCode.InternalServerError, cause)
-}
-
-internal fun invalidInputError(errorMessage: String, cause: Throwable? = null): AblyException {
- return ablyException(errorMessage, ErrorCode.InvalidInputParams, HttpStatusCode.InternalServerError, cause)
-}
-
-/**
- * Calculates the byte size of a string.
- * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference.
- * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4.
- */
-internal val String.byteSize: Int
- get() = this.toByteArray(StandardCharsets.UTF_8).size
-
-/**
- * A channel-specific coroutine scope for executing callbacks asynchronously in the RealtimeObjects system.
- * Provides safe execution of suspend functions with results delivered via callbacks.
- * Supports proper error handling and cancellation during DefaultRealtimeObjects disposal.
- */
-internal class ObjectsAsyncScope(channelName: String) {
- private val tag = "ObjectsCallbackScope-$channelName"
-
- private val scope =
- CoroutineScope(Dispatchers.Default + CoroutineName(tag) + SupervisorJob())
-
- internal fun launchWithCallback(callback: ObjectsCallback, block: suspend () -> T) {
- scope.launch {
- try {
- val result = block()
- try { callback.onSuccess(result) } 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) {
- when (throwable) {
- is AblyException -> { callback.onError(throwable) }
- else -> {
- val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable)
- callback.onError(ex)
- }
- }
- }
- }
- }
-
- internal fun launchWithVoidCallback(callback: ObjectsCallback, 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) {
- when (throwable) {
- is AblyException -> { callback.onError(throwable) }
- else -> {
- val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable)
- callback.onError(ex)
- }
- }
- }
- }
- }
-
- internal fun cancel(cause: CancellationException) {
- scope.coroutineContext.cancelChildren(cause)
- }
-}
-
-/**
- * Generates a random nonce string for object creation.
- */
-internal fun generateNonce(): String {
- val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" // avoid calculation using range
- return (1..16).map { chars.random() }.joinToString("")
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt
deleted file mode 100644
index 934789789..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt
+++ /dev/null
@@ -1,231 +0,0 @@
-package io.ably.lib.objects.type
-
-import io.ably.lib.objects.ObjectMessage
-import io.ably.lib.objects.ObjectOperation
-import io.ably.lib.objects.ObjectState
-import io.ably.lib.objects.ObjectsOperationSource
-import io.ably.lib.objects.objectError
-import io.ably.lib.objects.type.livecounter.noOpCounterUpdate
-import io.ably.lib.objects.type.livemap.noOpMapUpdate
-import io.ably.lib.util.Clock
-import io.ably.lib.util.Log
-import io.ably.lib.util.SystemClock
-
-internal enum class ObjectType(val value: String) {
- Map("map"),
- Counter("counter")
-}
-
-// Spec: RTLO4b4b
-internal val ObjectUpdate.noOp get() = this.update == null
-
-/**
- * Provides common functionality and base implementation for LiveMap and LiveCounter.
- *
- * @spec RTLO1/RTLO2 - Base class for LiveMap/LiveCounter object
- *
- * This should also be included in logging
- */
-internal abstract class BaseRealtimeObject(
- internal val objectId: String, // // RTLO3a
- internal val objectType: ObjectType,
- internal val clock: Clock = SystemClock.INSTANCE,
-) : ObjectLifecycleCoordinator() {
-
- protected open val tag = "BaseRealtimeObject"
-
- internal val siteTimeserials = mutableMapOf() // RTLO3b
-
- internal var createOperationIsMerged = false // RTLO3c
-
- @Volatile
- internal var isTombstoned = false // Accessed from public API for LiveMap/LiveCounter
-
- private var tombstonedAt: Long? = null
-
- /**
- * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object_sync`
- * @return an update describing the changes
- *
- * @spec RTLM6/RTLC6 - Overrides ObjectMessage with object data state from sync to LiveMap/LiveCounter
- */
- internal fun applyObjectSync(objectMessage: ObjectMessage): ObjectUpdate {
- val objectState = objectMessage.objectState as ObjectState // we have non-null objectState here due to RTO5f
- validate(objectState)
- // object's site serials are still updated even if it is tombstoned, so always use the site serials received from the operation.
- // should default to empty map if site serials do not exist on the object state, so that any future operation may be applied to this object.
- siteTimeserials.clear()
- siteTimeserials.putAll(objectState.siteTimeserials) // RTLC6a, RTLM6a
-
- if (isTombstoned) {
- // this object is tombstoned. this is a terminal state which can't be overridden. skip the rest of object state message processing
- if (objectType == ObjectType.Map) {
- return noOpMapUpdate
- }
- return noOpCounterUpdate
- }
- return applyObjectState(objectState, objectMessage) // RTLM6, RTLC6
- }
-
- /**
- * This is invoked by ObjectMessage having updated data with parent `ProtocolMessageAction` as `object`
- * @return true if the operation was meaningfully applied, false otherwise
- *
- * @spec RTLM15/RTLC7 - Applies ObjectMessage with object data operations to LiveMap/LiveCounter
- */
- internal fun applyObject(objectMessage: ObjectMessage, source: ObjectsOperationSource): Boolean {
- validateObjectId(objectMessage.operation?.objectId)
-
- val msgTimeSerial = objectMessage.serial
- val msgSiteCode = objectMessage.siteCode
- val objectOperation = objectMessage.operation as ObjectOperation
-
- if (!canApplyOperation(msgSiteCode, msgTimeSerial)) {
- // RTLC7b, RTLM15b
- Log.v(
- tag,
- "Skipping ${objectOperation.action} op: op serial $msgTimeSerial <= site serial ${siteTimeserials[msgSiteCode]}; " +
- "objectId=$objectId"
- )
- return false // RTLC7b / RTLM15b
- }
- // RTLC7c / RTLM15c - only update siteTimeserials for CHANNEL source
- if (source == ObjectsOperationSource.CHANNEL) {
- siteTimeserials[msgSiteCode!!] = msgTimeSerial!! // RTLC7c, RTLM15c
- }
-
- if (isTombstoned) {
- // this object is tombstoned so the operation cannot be applied
- return false // RTLC7e / RTLM15e
- }
- return applyObjectOperation(objectOperation, objectMessage) // RTLC7d
- }
-
- /**
- * Checks if an operation can be applied based on serial comparison.
- *
- * @spec RTLO4a - Serial comparison logic for LiveMap/LiveCounter operations
- */
- internal fun canApplyOperation(siteCode: String?, timeSerial: String?): Boolean {
- if (timeSerial.isNullOrEmpty()) {
- throw objectError("Invalid serial: $timeSerial") // RTLO4a3
- }
- if (siteCode.isNullOrEmpty()) {
- throw objectError("Invalid site code: $siteCode") // RTLO4a3
- }
- val existingSiteSerial = siteTimeserials[siteCode] // RTLO4a4
- return existingSiteSerial == null || timeSerial > existingSiteSerial // RTLO4a5, RTLO4a6
- }
-
- internal fun validateObjectId(objectId: String?) {
- if (this.objectId != objectId) {
- throw objectError("Invalid object: incoming objectId=$objectId; $objectType objectId=${this.objectId}")
- }
- }
-
- /**
- * Marks the object as tombstoned.
- */
- internal fun tombstone(serialTimestamp: Long?): ObjectUpdate {
- if (serialTimestamp == null) {
- Log.w(tag, "Tombstoning object $objectId without serial timestamp, using local timestamp instead")
- }
- isTombstoned = true
- tombstonedAt = serialTimestamp?: clock.currentTimeMillis()
- val update = clearData()
- // Emit object lifecycle event for deletion
- objectLifecycleChanged(ObjectLifecycle.Deleted)
- return update
- }
-
- /**
- * Checks if the object is eligible for garbage collection.
- *
- * An object is eligible for garbage collection if it has been tombstoned and
- * the time since tombstoning exceeds the specified grace period.
- *
- * @param gcGracePeriod The grace period in milliseconds that tombstoned objects
- * should be kept before being eligible for collection.
- * This value is retrieved from the server's connection details
- * or defaults to 24 hours if not provided by the server.
- * @return true if the object is tombstoned and the grace period has elapsed,
- * false otherwise
- */
- internal fun isEligibleForGc(gcGracePeriod: Long): Boolean {
- val currentTime = clock.currentTimeMillis()
- return isTombstoned && tombstonedAt?.let { currentTime - it >= gcGracePeriod } == true
- }
-
- /**
- * Validates that the provided object state is compatible with this object.
- * Checks object ID, type-specific validations, and any included create operations.
- */
- abstract fun validate(state: ObjectState)
-
- /**
- * Applies an object state received during synchronization to this object.
- * This method should update the internal data structure with the complete state
- * received from the server.
- *
- * @param objectState The complete state to apply to this object
- * @return A map describing the changes made to the object's data
- *
- */
- abstract fun applyObjectState(objectState: ObjectState, message: ObjectMessage): ObjectUpdate
-
- /**
- * Applies an operation to this object.
- * This method handles the specific operation actions (e.g., update, remove)
- * by modifying the underlying data structure accordingly.
- *
- * @param operation The operation containing the action and data to apply
- * @param message The complete object message containing the operation
- * @return true if the operation was meaningfully applied, false otherwise
- *
- */
- abstract fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean
-
- /**
- * Clears the object's data and returns an update describing the changes.
- * This is called during tombstoning and explicit clear operations.
- *
- * This method:
- * 1. Calculates a diff between the current state and an empty state
- * 2. Clears all entries from the underlying data structure
- * 3. Returns a map containing metadata about what was cleared
- *
- * The returned map is used to notifying other components about what entries were removed.
- *
- * @return A map representing the diff of changes made
- */
- abstract fun clearData(): ObjectUpdate
-
- /**
- * Notifies subscribers about changes made to this object. Propagates updates through the
- * appropriate manager after converting the generic update map to type-specific update objects.
- * Spec: RTLO4b4c
- */
- abstract fun notifyUpdated(update: ObjectUpdate)
-
- /**
- * Called during garbage collection intervals to clean up expired entries.
- *
- * This method is invoked periodically (every 5 minutes) by the ObjectsPool
- * to perform cleanup of tombstoned data that has exceeded the grace period.
- *
- * This method should identify and remove entries that:
- * - Have been marked as tombstoned
- * - Have a tombstone timestamp older than the specified grace period
- *
- * @param gcGracePeriod The grace period in milliseconds that tombstoned entries
- * should be kept before being eligible for removal.
- * This value is retrieved from the server's connection details
- * or defaults to 24 hours if not provided by the server.
- * Must be greater than 2 minutes to ensure proper operation
- * ordering and avoid issues with delayed operations.
- *
- * Implementations typically use single-pass removal techniques to
- * efficiently clean up expired data without creating temporary collections.
- */
- abstract fun onGCInterval(gcGracePeriod: Long)
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt
deleted file mode 100644
index 70abdea85..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt
+++ /dev/null
@@ -1,84 +0,0 @@
-package io.ably.lib.objects.type
-
-import io.ably.lib.objects.ObjectsSubscription
-import io.ably.lib.util.EventEmitter
-import io.ably.lib.util.Log
-
-/**
- * Internal enum representing object lifecycle states
- */
-internal enum class ObjectLifecycle {
- Created,
- Active,
- Deleted
-}
-
-/**
- * Maps internal ObjectLifecycle values to their corresponding public ObjectLifecycleEvent values.
- * Used to determine which events should be emitted when lifecycle changes occur.
- * CREATED and ACTIVE map to null (no public event), while DELETED maps to the public DELETED event.
- */
-private val objectLifecycleToEventMap = mapOf(
- ObjectLifecycle.Created to null,
- ObjectLifecycle.Active to null,
- ObjectLifecycle.Deleted to ObjectLifecycleEvent.DELETED
-)
-
-/**
- * An interface for managing and communicating changes in the lifecycle state of objects.
- *
- * Implementations should ensure thread-safe event emission and proper lifecycle
- * event notifications.
- */
-internal interface HandlesObjectLifecycleChange {
- /**
- * Handles changes in the lifecycle of objects by notifying all registered listeners.
- * Implementations should ensure thread-safe event emission to both internal and public listeners.
- * Makes sure every event is processed in the order they were received.
- * @param newLifecycle The new lifecycle state of the object.
- */
- fun objectLifecycleChanged(newLifecycle: ObjectLifecycle)
-
- /**
- * Disposes all registered lifecycle change listeners and cancels any pending operations.
- * Should be called when the associated object is no longer needed.
- */
- fun disposeObjectLifecycleListeners()
-}
-
-internal abstract class ObjectLifecycleCoordinator : ObjectLifecycleChange, HandlesObjectLifecycleChange {
- private val tag = "ObjectLifecycleCoordinator"
- // EventEmitter for users of the library
- private val objectLifecycleEmitter = ObjectLifecycleEmitter()
-
- override fun on(event: ObjectLifecycleEvent, listener: ObjectLifecycleChange.Listener): ObjectsSubscription {
- objectLifecycleEmitter.on(event, listener)
- return ObjectsSubscription {
- objectLifecycleEmitter.off(event, listener)
- }
- }
-
- override fun off(listener: ObjectLifecycleChange.Listener) = objectLifecycleEmitter.off(listener)
-
- override fun offAll() = objectLifecycleEmitter.off()
-
- override fun objectLifecycleChanged(newLifecycle: ObjectLifecycle) {
- objectLifecycleToEventMap[newLifecycle]?.let { objectLifecycleEvent ->
- objectLifecycleEmitter.emit(objectLifecycleEvent)
- }
- }
-
- override fun disposeObjectLifecycleListeners() = offAll()
-}
-
-private class ObjectLifecycleEmitter : EventEmitter() {
- private val tag = "ObjectLifecycleEmitter"
- override fun apply(listener: ObjectLifecycleChange.Listener?, event: ObjectLifecycleEvent?, vararg args: Any?) {
- try {
- event?.let { listener?.onLifecycleEvent(it) }
- ?: Log.w(tag, "Null event passed to ObjectLifecycleChange listener callback")
- } catch (t: Throwable) {
- Log.e(tag, "Error occurred while executing listener callback for event: $event", t)
- }
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt
deleted file mode 100644
index f6a9ee6c6..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt
+++ /dev/null
@@ -1,137 +0,0 @@
-package io.ably.lib.objects.type.livecounter
-
-import io.ably.lib.objects.*
-import io.ably.lib.objects.CounterCreate
-import io.ably.lib.objects.CounterInc
-import io.ably.lib.objects.ObjectOperation
-import io.ably.lib.objects.ObjectState
-import io.ably.lib.objects.type.BaseRealtimeObject
-import io.ably.lib.objects.type.ObjectUpdate
-import io.ably.lib.objects.type.ObjectType
-import io.ably.lib.objects.type.counter.LiveCounter
-import io.ably.lib.objects.type.counter.LiveCounterChange
-import io.ably.lib.objects.type.counter.LiveCounterUpdate
-import io.ably.lib.objects.type.noOp
-import java.util.concurrent.atomic.AtomicReference
-import io.ably.lib.util.Log
-import io.ably.lib.util.SystemClock
-import kotlinx.coroutines.runBlocking
-
-/**
- * @spec RTLC1/RTLC2 - LiveCounter implementation extends BaseRealtimeObject
- */
-internal class DefaultLiveCounter private constructor(
- objectId: String,
- private val realtimeObjects: DefaultRealtimeObjects,
-) : LiveCounter, BaseRealtimeObject(objectId, ObjectType.Counter, realtimeObjects.clock) {
-
- override val tag = "LiveCounter"
-
- /**
- * Thread-safe reference to hold the counter data value.
- * Accessed from public API for LiveCounter and updated by LiveCounterManager.
- */
- internal val data = AtomicReference(0.0) // RTLC3
-
- /**
- * liveCounterManager instance for managing LiveCounter operations
- */
- private val liveCounterManager = LiveCounterManager(this)
-
- private val channelName = realtimeObjects.channelName
- private val adapter: ObjectsAdapter get() = realtimeObjects.adapter
- private val asyncScope get() = realtimeObjects.asyncScope
-
- override fun increment(amount: Number) = runBlocking { incrementAsync(amount.toDouble()) }
-
- override fun decrement(amount: Number) = runBlocking { incrementAsync(-amount.toDouble()) }
-
- override fun incrementAsync(amount: Number, callback: ObjectsCallback) {
- asyncScope.launchWithVoidCallback(callback) { incrementAsync(amount.toDouble()) }
- }
-
- override fun decrementAsync(amount: Number, callback: ObjectsCallback) {
- asyncScope.launchWithVoidCallback(callback) { incrementAsync(-amount.toDouble()) }
- }
-
- override fun value(): Double {
- adapter.throwIfInvalidAccessApiConfiguration(channelName)
- return data.get()
- }
-
- override fun subscribe(listener: LiveCounterChange.Listener): ObjectsSubscription {
- adapter.throwIfInvalidAccessApiConfiguration(channelName)
- return liveCounterManager.subscribe(listener)
- }
-
- override fun unsubscribe(listener: LiveCounterChange.Listener) = liveCounterManager.unsubscribe(listener)
-
- override fun unsubscribeAll() = liveCounterManager.unsubscribeAll()
-
- override fun validate(state: ObjectState) = liveCounterManager.validate(state)
-
- private suspend fun incrementAsync(amount: Double) {
- // RTLC12b, RTLC12c, RTLC12d - Validate write API configuration
- adapter.throwIfInvalidWriteApiConfiguration(channelName)
-
- // RTLC12e1 - Validate input parameter
- if (amount.isNaN() || amount.isInfinite()) {
- throw invalidInputError("Counter value increment should be a valid number")
- }
-
- // RTLC12e2, RTLC12e3, RTLC12e4 - Create ObjectMessage with the COUNTER_INC operation
- val msg = ObjectMessage(
- operation = ObjectOperation(
- action = ObjectOperationAction.CounterInc,
- objectId = objectId,
- counterInc = CounterInc(number = amount)
- )
- )
-
- // RTLC12g - publish and apply locally on ACK
- realtimeObjects.publishAndApply(arrayOf(msg))
- }
-
- override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveCounterUpdate {
- return liveCounterManager.applyState(objectState, message.serialTimestamp)
- }
-
- override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean {
- return liveCounterManager.applyOperation(operation, message.serialTimestamp)
- }
-
- override fun clearData(): LiveCounterUpdate {
- return liveCounterManager.calculateUpdateFromDataDiff(data.get(), 0.0).apply { data.set(0.0) }
- }
-
- override fun notifyUpdated(update: ObjectUpdate) {
- if (update.noOp) {
- return
- }
- Log.v(tag, "Object $objectId updated: $update")
- liveCounterManager.notify(update as LiveCounterUpdate)
- }
-
- override fun onGCInterval(gcGracePeriod: Long) {
- // Nothing to GC for a counter object
- return
- }
-
- companion object {
- /**
- * Creates a zero-value counter object.
- * @spec RTLC4 - Returns LiveCounter with 0 value
- */
- internal fun zeroValue(objectId: String, realtimeObjects: DefaultRealtimeObjects): DefaultLiveCounter {
- return DefaultLiveCounter(objectId, realtimeObjects)
- }
-
- /**
- * Creates initial value payload for counter creation.
- * Spec: RTO12f12
- */
- internal fun initialValue(count: Number): CounterCreate {
- return CounterCreate(count = count.toDouble())
- }
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt
deleted file mode 100644
index a1940dc04..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package io.ably.lib.objects.type.livecounter
-
-import io.ably.lib.objects.ObjectsSubscription
-import io.ably.lib.objects.type.counter.LiveCounterChange
-import io.ably.lib.objects.type.counter.LiveCounterUpdate
-import io.ably.lib.util.EventEmitter
-import io.ably.lib.util.Log
-
-internal val noOpCounterUpdate = LiveCounterUpdate()
-
-/**
- * Interface for handling live counter changes by notifying subscribers of updates.
- * Implementations typically propagate updates through event emission to registered listeners.
- */
-internal interface HandlesLiveCounterChange {
- /**
- * Notifies all registered listeners about a counter update by propagating the change through the event system.
- * This method is called when counter data changes and triggers the emission of update events to subscribers.
- */
- fun notify(update: LiveCounterUpdate)
-}
-
-internal abstract class LiveCounterChangeCoordinator: LiveCounterChange, HandlesLiveCounterChange {
- private val counterChangeEmitter = LiveCounterChangeEmitter()
-
- override fun subscribe(listener: LiveCounterChange.Listener): ObjectsSubscription {
- counterChangeEmitter.on(listener)
- return ObjectsSubscription {
- counterChangeEmitter.off(listener)
- }
- }
-
- override fun unsubscribe(listener: LiveCounterChange.Listener) = counterChangeEmitter.off(listener)
-
- override fun unsubscribeAll() = counterChangeEmitter.off()
-
- override fun notify(update: LiveCounterUpdate) = counterChangeEmitter.emit(update)
-}
-
-private class LiveCounterChangeEmitter : EventEmitter() {
- private val tag = "LiveCounterChangeEmitter"
-
- override fun apply(listener: LiveCounterChange.Listener?, event: LiveCounterUpdate?, vararg args: Any?) {
- try {
- event?.let { listener?.onUpdated(it) }
- ?: Log.w(tag, "Null event passed to LiveCounterChange listener callback")
- } catch (t: Throwable) {
- Log.e(tag, "Error occurred while executing listener callback for event: $event", t)
- }
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt
deleted file mode 100644
index b9c35bc37..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt
+++ /dev/null
@@ -1,134 +0,0 @@
-package io.ably.lib.objects.type.livecounter
-
-import io.ably.lib.objects.*
-import io.ably.lib.objects.CounterInc
-import io.ably.lib.objects.ObjectOperation
-import io.ably.lib.objects.ObjectOperationAction
-import io.ably.lib.objects.ObjectState
-import io.ably.lib.objects.objectError
-import io.ably.lib.objects.type.counter.LiveCounterUpdate
-import io.ably.lib.util.Log
-
-internal class LiveCounterManager(private val liveCounter: DefaultLiveCounter): LiveCounterChangeCoordinator() {
-
- private val objectId = liveCounter.objectId
-
- private val tag = "LiveCounterManager"
-
- /**
- * @spec RTLC6 - Overrides counter data with state from sync
- */
- internal fun applyState(objectState: ObjectState, serialTimestamp: Long?): LiveCounterUpdate {
- val previousData = liveCounter.data.get()
-
- if (objectState.tombstone) {
- liveCounter.tombstone(serialTimestamp)
- } else {
- // override data for this object with data from the object state
- liveCounter.createOperationIsMerged = false // RTLC6b
- liveCounter.data.set(objectState.counter?.count ?: 0.0) // RTLC6c
-
- // RTLC6d
- objectState.createOp?.let { createOp ->
- mergeInitialDataFromCreateOperation(createOp)
- }
- }
-
- return calculateUpdateFromDataDiff(previousData, liveCounter.data.get())
- }
-
- /**
- * @spec RTLC7 - Applies operations to LiveCounter
- */
- internal fun applyOperation(operation: ObjectOperation, serialTimestamp: Long?): Boolean {
- return when (operation.action) {
- ObjectOperationAction.CounterCreate -> {
- val update = applyCounterCreate(operation) // RTLC7d1
- liveCounter.notifyUpdated(update) // RTLC7d1a
- true // RTLC7d1b
- }
- ObjectOperationAction.CounterInc -> {
- if (operation.counterInc != null) {
- val update = applyCounterInc(operation.counterInc) // RTLC7d2
- liveCounter.notifyUpdated(update) // RTLC7d2a
- true // RTLC7d2b
- } else {
- throw objectError("No payload found for ${operation.action} op for LiveCounter objectId=${objectId}")
- }
- }
- ObjectOperationAction.ObjectDelete -> {
- val update = liveCounter.tombstone(serialTimestamp)
- liveCounter.notifyUpdated(update)
- true // RTLC7d4b
- }
- else -> {
- Log.w(tag, "Invalid ${operation.action} op for LiveCounter objectId=${objectId}") // RTLC7d3
- false
- }
- }
- }
-
- /**
- * @spec RTLC8 - Applies counter create operation
- */
- private fun applyCounterCreate(operation: ObjectOperation): LiveCounterUpdate {
- if (liveCounter.createOperationIsMerged) {
- // RTLC8b
- // There can't be two different create operation for the same object id, because the object id
- // fully encodes that operation. This means we can safely ignore any new incoming create operations
- // if we already merged it once.
- Log.v(
- tag,
- "Skipping applying COUNTER_CREATE op on a counter instance as it was already applied before; objectId=$objectId"
- )
- return noOpCounterUpdate // RTLC8c
- }
-
- return mergeInitialDataFromCreateOperation(operation) // RTLC8c
- }
-
- /**
- * @spec RTLC9 - Applies counter increment operation
- */
- private fun applyCounterInc(counterInc: CounterInc): LiveCounterUpdate {
- val amount = counterInc.number
- val previousValue = liveCounter.data.get()
- liveCounter.data.set(previousValue + amount) // RTLC9f
- return LiveCounterUpdate(amount)
- }
-
- internal fun calculateUpdateFromDataDiff(prevData: Double, newData: Double): LiveCounterUpdate {
- return LiveCounterUpdate(newData - prevData)
- }
-
- /**
- * @spec RTLC16 - Merges initial data from create operation
- */
- private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): LiveCounterUpdate {
- // if a counter object is missing for the COUNTER_CREATE op, the initial value is implicitly 0 in this case.
- // note that it is intentional to SUM the incoming count from the create op.
- // if we got here, it means that current counter instance is missing the initial value in its data reference,
- // which we're going to add now.
- val count = operation.counterCreateWithObjectId?.derivedFrom?.count
- ?: operation.counterCreate?.count
- ?: 0.0
- val previousValue = liveCounter.data.get()
- liveCounter.data.set(previousValue + count) // RTLC16
- liveCounter.createOperationIsMerged = true // RTLC16
- return LiveCounterUpdate(count)
- }
-
- internal fun validate(state: ObjectState) {
- liveCounter.validateObjectId(state.objectId)
- state.createOp?.let { createOp ->
- liveCounter.validateObjectId(createOp.objectId)
- validateCounterCreateAction(createOp.action)
- }
- }
-
- private fun validateCounterCreateAction(action: ObjectOperationAction) {
- if (action != ObjectOperationAction.CounterCreate) {
- throw objectError("Invalid create operation action $action for LiveCounter objectId=${objectId}")
- }
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt
deleted file mode 100644
index da5cee9b4..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt
+++ /dev/null
@@ -1,247 +0,0 @@
-package io.ably.lib.objects.type.livemap
-
-import io.ably.lib.objects.*
-import io.ably.lib.objects.MapCreate
-import io.ably.lib.objects.MapRemove
-import io.ably.lib.objects.MapSet
-import io.ably.lib.objects.ObjectsMapSemantics
-import io.ably.lib.objects.ObjectMessage
-import io.ably.lib.objects.ObjectOperation
-import io.ably.lib.objects.ObjectState
-import io.ably.lib.objects.type.BaseRealtimeObject
-import io.ably.lib.objects.type.ObjectUpdate
-import io.ably.lib.objects.type.ObjectType
-import io.ably.lib.objects.type.map.LiveMap
-import io.ably.lib.objects.type.map.LiveMapChange
-import io.ably.lib.objects.type.map.LiveMapUpdate
-import io.ably.lib.objects.type.map.LiveMapValue
-import io.ably.lib.objects.type.noOp
-import io.ably.lib.util.Log
-import io.ably.lib.util.SystemClock
-import kotlinx.coroutines.runBlocking
-import java.util.Base64
-import java.util.concurrent.ConcurrentHashMap
-import java.util.AbstractMap
-
-/**
- * @spec RTLM1/RTLM2 - LiveMap implementation extends BaseRealtimeObject
- */
-internal class DefaultLiveMap private constructor(
- objectId: String,
- private val realtimeObjects: DefaultRealtimeObjects,
- internal val semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW
-) : LiveMap, BaseRealtimeObject(objectId, ObjectType.Map, realtimeObjects.clock) {
-
- override val tag = "LiveMap"
-
- /**
- * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveMapManager.
- */
- internal val data = ConcurrentHashMap()
-
- /** @spec RTLM25 */
- internal var clearTimeserial: String? = null
-
- /**
- * LiveMapManager instance for managing LiveMap operations
- */
- private val liveMapManager = LiveMapManager(this)
-
- private val channelName = realtimeObjects.channelName
- private val adapter: ObjectsAdapter get() = realtimeObjects.adapter
- internal val objectsPool: ObjectsPool get() = realtimeObjects.objectsPool
- private val asyncScope get() = realtimeObjects.asyncScope
-
- override fun get(keyName: String): LiveMapValue? {
- adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM5b, RTLM5c
- if (isTombstoned) {
- return null
- }
- data[keyName]?.let { liveMapEntry ->
- return liveMapEntry.getResolvedValue(objectsPool)
- }
- return null // RTLM5d1
- }
-
- override fun entries(): Iterable> {
- adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM11b, RTLM11c
-
- return sequence> {
- for ((key, entry) in data.entries) {
- val value = entry.getResolvedValue(objectsPool) // RTLM11d, RTLM11d2
- value?.let {
- yield(AbstractMap.SimpleImmutableEntry(key, it))
- }
- }
- }.asIterable()
- }
-
- override fun keys(): Iterable {
- val iterableEntries = entries()
- return sequence {
- for (entry in iterableEntries) {
- yield(entry.key) // RTLM12b
- }
- }.asIterable()
- }
-
- override fun values(): Iterable {
- val iterableEntries = entries()
- return sequence {
- for (entry in iterableEntries) {
- yield(entry.value) // RTLM13b
- }
- }.asIterable()
- }
-
- override fun size(): Long {
- adapter.throwIfInvalidAccessApiConfiguration(channelName)
- return data.values.count { !it.isEntryOrRefTombstoned(objectsPool) }.toLong() // RTLM10d
- }
-
- override fun set(keyName: String, value: LiveMapValue) = runBlocking { setAsync(keyName, value) }
-
- override fun remove(keyName: String) = runBlocking { removeAsync(keyName) }
-
- override fun setAsync(keyName: String, value: LiveMapValue, callback: ObjectsCallback) {
- asyncScope.launchWithVoidCallback(callback) { setAsync(keyName, value) }
- }
-
- override fun removeAsync(keyName: String, callback: ObjectsCallback) {
- asyncScope.launchWithVoidCallback(callback) { removeAsync(keyName) }
- }
-
- override fun validate(state: ObjectState) = liveMapManager.validate(state)
-
- override fun subscribe(listener: LiveMapChange.Listener): ObjectsSubscription {
- adapter.throwIfInvalidAccessApiConfiguration(channelName)
- return liveMapManager.subscribe(listener)
- }
-
- override fun unsubscribe(listener: LiveMapChange.Listener) = liveMapManager.unsubscribe(listener)
-
- override fun unsubscribeAll() = liveMapManager.unsubscribeAll()
-
- private suspend fun setAsync(keyName: String, value: LiveMapValue) {
- // RTLM20b, RTLM20c, RTLM20d - Validate write API configuration
- adapter.throwIfInvalidWriteApiConfiguration(channelName)
-
- // Validate input parameters
- if (keyName.isEmpty()) {
- throw invalidInputError("Map key should not be empty")
- }
-
- // RTLM20e - Create ObjectMessage with the MAP_SET operation
- val msg = ObjectMessage(
- operation = ObjectOperation(
- action = ObjectOperationAction.MapSet,
- objectId = objectId,
- mapSet = MapSet(
- key = keyName,
- value = fromLiveMapValue(value)
- )
- )
- )
-
- // RTLM20g - publish and apply locally on ACK
- realtimeObjects.publishAndApply(arrayOf(msg))
- }
-
- private suspend fun removeAsync(keyName: String) {
- // RTLM21b, RTLM21cm RTLM21d - Validate write API configuration
- adapter.throwIfInvalidWriteApiConfiguration(channelName)
-
- // Validate input parameter
- if (keyName.isEmpty()) {
- throw invalidInputError("Map key should not be empty")
- }
-
- // RTLM21e - Create ObjectMessage with the MAP_REMOVE operation
- val msg = ObjectMessage(
- operation = ObjectOperation(
- action = ObjectOperationAction.MapRemove,
- objectId = objectId,
- mapRemove = MapRemove(key = keyName)
- )
- )
-
- // RTLM21g - publish and apply locally on ACK
- realtimeObjects.publishAndApply(arrayOf(msg))
- }
-
- override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveMapUpdate {
- return liveMapManager.applyState(objectState, message.serialTimestamp)
- }
-
- override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean {
- return liveMapManager.applyOperation(operation, message.serial, message.serialTimestamp)
- }
-
- override fun clearData(): LiveMapUpdate {
- clearTimeserial = null // RTLM4
- return liveMapManager.calculateUpdateFromDataDiff(data.toMap(), emptyMap())
- .apply { data.clear() }
- }
-
- override fun notifyUpdated(update: ObjectUpdate) {
- if (update.noOp) {
- return
- }
- Log.v(tag, "Object $objectId updated: $update")
- liveMapManager.notify(update as LiveMapUpdate)
- }
-
- override fun onGCInterval(gcGracePeriod: Long) {
- data.entries.removeIf { (_, entry) -> entry.isEligibleForGc(gcGracePeriod, clock) }
- }
-
- companion object {
- /**
- * Creates a zero-value map object.
- * @spec RTLM4 - Returns LiveMap with empty map data
- */
- internal fun zeroValue(objectId: String, objects: DefaultRealtimeObjects): DefaultLiveMap {
- return DefaultLiveMap(objectId, objects)
- }
-
- /**
- * Creates a MapCreate payload from map entries.
- * Spec: RTO11f14
- */
- internal fun initialValue(entries: MutableMap): MapCreate {
- return MapCreate(
- semantics = ObjectsMapSemantics.LWW,
- entries = entries.mapValues { (_, value) ->
- ObjectsMapEntry(
- tombstone = false,
- data = fromLiveMapValue(value)
- )
- }
- )
- }
-
- /**
- * Spec: RTLM20e5
- */
- private fun fromLiveMapValue(value: LiveMapValue): ObjectData {
- return when {
- value.isLiveMap || value.isLiveCounter ->
- ObjectData(objectId = (value.value as BaseRealtimeObject).objectId)
- value.isBoolean ->
- ObjectData(boolean = value.asBoolean)
- value.isBinary ->
- ObjectData(bytes = Base64.getEncoder().encodeToString(value.asBinary))
- value.isNumber ->
- ObjectData(number = value.asNumber.toDouble())
- value.isString ->
- ObjectData(string = value.asString)
- value.isJsonObject ->
- ObjectData(json = value.asJsonObject)
- value.isJsonArray ->
- ObjectData(json = value.asJsonArray)
- else ->
- throw IllegalArgumentException("Unsupported value type")
- }
- }
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt
deleted file mode 100644
index 8bed43497..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package io.ably.lib.objects.type.livemap
-
-import io.ably.lib.objects.ObjectsSubscription
-import io.ably.lib.objects.type.map.LiveMapChange
-import io.ably.lib.objects.type.map.LiveMapUpdate
-import io.ably.lib.util.EventEmitter
-import io.ably.lib.util.Log
-
-internal val noOpMapUpdate = LiveMapUpdate()
-
-/**
- * Interface for handling live map changes by notifying subscribers of updates.
- * Implementations typically propagate updates through event emission to registered listeners.
- */
-internal interface HandlesLiveMapChange {
- /**
- * Notifies all registered listeners about a map update by propagating the change through the event system.
- * This method is called when map data changes and triggers the emission of update events to subscribers.
- */
- fun notify(update: LiveMapUpdate)
-}
-
-internal abstract class LiveMapChangeCoordinator: LiveMapChange, HandlesLiveMapChange {
- private val mapChangeEmitter = LiveMapChangeEmitter()
-
- override fun subscribe(listener: LiveMapChange.Listener): ObjectsSubscription {
- mapChangeEmitter.on(listener)
- return ObjectsSubscription {
- mapChangeEmitter.off(listener)
- }
- }
-
- override fun unsubscribe(listener: LiveMapChange.Listener) = mapChangeEmitter.off(listener)
-
- override fun unsubscribeAll() = mapChangeEmitter.off()
-
- override fun notify(update: LiveMapUpdate) = mapChangeEmitter.emit(update)
-}
-
-private class LiveMapChangeEmitter : EventEmitter() {
- private val tag = "LiveMapChangeEmitter"
-
- override fun apply(listener: LiveMapChange.Listener?, event: LiveMapUpdate?, vararg args: Any?) {
- try {
- event?.let { listener?.onUpdated(it) }
- ?: Log.w(tag, "Null event passed to LiveMapChange listener callback")
- } catch (t: Throwable) {
- Log.e(tag, "Error occurred while executing listener callback for event: $event", t)
- }
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt
deleted file mode 100644
index 2b21a7f2f..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-package io.ably.lib.objects.type.livemap
-
-import io.ably.lib.objects.*
-import io.ably.lib.objects.ObjectData
-import io.ably.lib.objects.ObjectsPool
-import io.ably.lib.objects.type.BaseRealtimeObject
-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
-import io.ably.lib.util.Clock
-import java.util.Base64
-
-/**
- * @spec RTLM3 - Map data structure storing entries
- */
-internal data class LiveMapEntry(
- val isTombstoned: Boolean = false,
- val tombstonedAt: Long? = null,
- val timeserial: String? = null,
- val data: ObjectData? = null
-)
-
-/**
- * Checks if entry is directly tombstoned or references a tombstoned object. Spec: RTLM14
- * @param objectsPool The object pool containing referenced DefaultRealtimeObjects
- */
-internal fun LiveMapEntry.isEntryOrRefTombstoned(objectsPool: ObjectsPool): Boolean {
- if (isTombstoned) {
- return true // RTLM14a
- }
- data?.objectId?.let { refId -> // RTLM5d2f -has an objectId reference
- objectsPool.get(refId)?.let { refObject ->
- if (refObject.isTombstoned) {
- return true
- }
- }
- }
- return false // RTLM14b
-}
-
-/**
- * Returns value as is if object data stores a primitive type or
- * a reference to another RealtimeObject from the pool if it stores an objectId.
- */
-internal fun LiveMapEntry.getResolvedValue(objectsPool: ObjectsPool): LiveMapValue? {
- if (isTombstoned) { return null } // RTLM5d2a
-
- data?.let { d -> // RTLM5d2b, RTLM5d2c, RTLM5d2d, RTLM5d2e
- d.string?.let { return LiveMapValue.of(it) }
- d.number?.let { return LiveMapValue.of(it) }
- d.boolean?.let { return LiveMapValue.of(it) }
- d.bytes?.let { return LiveMapValue.of(Base64.getDecoder().decode(it)) }
- d.json?.let { parsed ->
- return when {
- parsed.isJsonObject -> LiveMapValue.of(parsed.asJsonObject)
- parsed.isJsonArray -> LiveMapValue.of(parsed.asJsonArray)
- else -> null
- }
- }
- d.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 fromRealtimeObject(refObject) // RTLM5d2f2
- }
- }
- }
- return null // RTLM5d2g, RTLM5d2f1
-}
-
-/**
- * Extension function to check if a LiveMapEntry is expired and ready for garbage collection
- */
-internal fun LiveMapEntry.isEligibleForGc(gcGracePeriod: Long, clock: Clock): Boolean {
- val currentTime = clock.currentTimeMillis()
- return isTombstoned && tombstonedAt?.let { currentTime - it >= gcGracePeriod } == true
-}
-
-private fun fromRealtimeObject(realtimeObject: BaseRealtimeObject): LiveMapValue {
- return when (realtimeObject.objectType) {
- ObjectType.Map -> LiveMapValue.of(realtimeObject as LiveMap)
- ObjectType.Counter -> LiveMapValue.of(realtimeObject as LiveCounter)
- }
-}
diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt
deleted file mode 100644
index 71cd4e4a2..000000000
--- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt
+++ /dev/null
@@ -1,410 +0,0 @@
-package io.ably.lib.objects.type.livemap
-
-import io.ably.lib.objects.MapCreate
-import io.ably.lib.objects.MapRemove
-import io.ably.lib.objects.MapSet
-import io.ably.lib.objects.ObjectsMapSemantics
-import io.ably.lib.objects.ObjectOperation
-import io.ably.lib.objects.ObjectOperationAction
-import io.ably.lib.objects.ObjectState
-import io.ably.lib.objects.isInvalid
-import io.ably.lib.objects.objectError
-import io.ably.lib.objects.type.map.LiveMapUpdate
-import io.ably.lib.objects.type.noOp
-import io.ably.lib.util.Log
-
-internal class LiveMapManager(private val liveMap: DefaultLiveMap): LiveMapChangeCoordinator() {
-
- private val objectId = liveMap.objectId
-
- private val tag = "LiveMapManager"
-
- /**
- * @spec RTLM6 - Overrides object data with state from sync
- */
- internal fun applyState(objectState: ObjectState, serialTimestamp: Long?): LiveMapUpdate {
- val previousData = liveMap.data.toMap()
-
- if (objectState.tombstone) {
- liveMap.tombstone(serialTimestamp)
- } else {
- // override data for this object with data from the object state
- liveMap.createOperationIsMerged = false // RTLM6b
- liveMap.data.clear()
-
- liveMap.clearTimeserial = objectState.map?.clearTimeserial // RTLM6i
-
- objectState.map?.entries?.forEach { (key, entry) ->
- liveMap.data[key] = LiveMapEntry(
- isTombstoned = entry.tombstone ?: false,
- tombstonedAt = if (entry.tombstone == true) entry.serialTimestamp ?: liveMap.clock.currentTimeMillis() else null,
- timeserial = entry.timeserial,
- data = entry.data
- )
- } // RTLM6c
-
- // RTLM6d
- objectState.createOp?.let { createOp ->
- mergeInitialDataFromCreateOperation(createOp)
- }
- }
-
- return calculateUpdateFromDataDiff(previousData, liveMap.data.toMap())
- }
-
- /**
- * @spec RTLM15 - Applies operations to LiveMap
- */
- internal fun applyOperation(operation: ObjectOperation, serial: String?, serialTimestamp: Long?): Boolean {
- return when (operation.action) {
- ObjectOperationAction.MapCreate -> {
- val update = applyMapCreate(operation) // RTLM15d1
- liveMap.notifyUpdated(update) // RTLM15d1a
- true // RTLM15d1b
- }
- ObjectOperationAction.MapSet -> {
- if (operation.mapSet != null) {
- val update = applyMapSet(operation.mapSet, serial) // RTLM15d2
- liveMap.notifyUpdated(update) // RTLM15d2a
- true // RTLM15d2b
- } else {
- throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}")
- }
- }
- ObjectOperationAction.MapRemove -> {
- if (operation.mapRemove != null) {
- val update = applyMapRemove(operation.mapRemove, serial, serialTimestamp) // RTLM15d3
- liveMap.notifyUpdated(update) // RTLM15d3a
- true // RTLM15d3b
- } else {
- throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}")
- }
- }
- ObjectOperationAction.ObjectDelete -> {
- val update = liveMap.tombstone(serialTimestamp)
- liveMap.notifyUpdated(update)
- true // RTLM15d5b
- }
- ObjectOperationAction.MapClear -> {
- val update = applyMapClear(serial) // RTLM15d8
- liveMap.notifyUpdated(update) // RTLM15d8a
- true // RTLM15d8b
- }
- else -> {
- Log.w(tag, "Invalid ${operation.action} op for LiveMap objectId=${objectId}") // RTLM15d4
- false
- }
- }
- }
-
- /**
- * @spec RTLM16 - Applies map create operation
- */
- private fun applyMapCreate(operation: ObjectOperation): LiveMapUpdate {
- if (liveMap.createOperationIsMerged) {
- // RTLM16b
- // There can't be two different create operation for the same object id, because the object id
- // fully encodes that operation. This means we can safely ignore any new incoming create operations
- // if we already merged it once.
- Log.v(
- tag,
- "Skipping applying MAP_CREATE op on a map instance as it was already applied before; objectId=${objectId}"
- )
- return noOpMapUpdate
- }
-
- validateMapSemantics(getEffectiveMapCreate(operation)?.semantics) // RTLM16c
-
- return mergeInitialDataFromCreateOperation(operation) // RTLM16d
- }
-
- /**
- * @spec RTLM7 - Applies MAP_SET operation to LiveMap
- */
- private fun applyMapSet(
- mapSet: MapSet, // RTLM7d1
- timeSerial: String?, // RTLM7d2
- ): LiveMapUpdate {
- // RTLM7h - skip if operation is older than the last MAP_CLEAR
- val clearSerial = liveMap.clearTimeserial
- if (clearSerial != null && (timeSerial == null || clearSerial >= timeSerial)) {
- Log.v(tag,
- "Skipping MAP_SET for key=\"${mapSet.key}\": op serial $timeSerial <= clear serial $clearSerial; objectId=$objectId")
- return noOpMapUpdate
- }
-
- val existingEntry = liveMap.data[mapSet.key]
-
- // RTLM7a
- if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) {
- // RTLM7a1 - the operation's serial <= the entry's serial, ignore the operation
- Log.v(tag,
- "Skipping update for key=\"${mapSet.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial};" +
- " objectId=${objectId}"
- )
- return noOpMapUpdate
- }
-
- if (mapSet.value.isInvalid()) {
- throw objectError("Invalid object data for MAP_SET op on objectId=${objectId} on key=${mapSet.key}")
- }
-
- // RTLM7c
- mapSet.value.objectId?.let {
- // this MAP_SET op is setting a key to point to another object via its object id,
- // but it is possible that we don't have the corresponding object in the pool yet (for example, we haven't seen the *_CREATE op for it).
- // we don't want to return undefined from this map's .get() method even if we don't have the object,
- // so instead we create a zero-value object for that object id if it not exists.
- liveMap.objectsPool.createZeroValueObjectIfNotExists(it) // RTLM7c1
- }
-
- if (existingEntry != null) {
- // RTLM7a2 - Replace existing entry with new one instead of mutating
- liveMap.data[mapSet.key] = LiveMapEntry(
- isTombstoned = false, // RTLM7a2c
- timeserial = timeSerial, // RTLM7a2b
- data = mapSet.value // RTLM7a2a
- )
- } else {
- // RTLM7b, RTLM7b1
- liveMap.data[mapSet.key] = LiveMapEntry(
- isTombstoned = false, // RTLM7b2
- timeserial = timeSerial,
- data = mapSet.value
- )
- }
-
- return LiveMapUpdate(mapOf(mapSet.key to LiveMapUpdate.Change.UPDATED))
- }
-
- /**
- * @spec RTLM8 - Applies MAP_REMOVE operation to LiveMap
- */
- private fun applyMapRemove(
- mapRemove: MapRemove, // RTLM8c1
- timeSerial: String?, // RTLM8c2
- timeStamp: Long?, // RTLM8c3
- ): LiveMapUpdate {
- // RTLM8g - skip if operation is older than the last MAP_CLEAR
- val clearSerial = liveMap.clearTimeserial
- if (clearSerial != null && (timeSerial == null || clearSerial >= timeSerial)) {
- Log.v(tag,
- "Skipping MAP_REMOVE for key=\"${mapRemove.key}\": op serial $timeSerial <= clear serial $clearSerial; objectId=$objectId")
- return noOpMapUpdate
- }
-
- val existingEntry = liveMap.data[mapRemove.key]
-
- // RTLM8a
- if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) {
- // RTLM8a1 - the operation's serial <= the entry's serial, ignore the operation
- Log.v(
- tag,
- "Skipping remove for key=\"${mapRemove.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial}; " +
- "objectId=${objectId}"
- )
- return noOpMapUpdate
- }
-
- val tombstonedAt = if (timeStamp != null) timeStamp else {
- Log.w(
- tag,
- "No timestamp provided for MAP_REMOVE op on key=\"${mapRemove.key}\"; using current time as tombstone time; " +
- "objectId=${objectId}"
- )
- liveMap.clock.currentTimeMillis()
- }
-
- if (existingEntry != null) {
- // RTLM8a2 - Replace existing entry with new one instead of mutating
- liveMap.data[mapRemove.key] = LiveMapEntry(
- isTombstoned = true, // RTLM8a2c
- tombstonedAt = tombstonedAt,
- timeserial = timeSerial, // RTLM8a2b
- data = null // RTLM8a2a
- )
- } else {
- // RTLM8b, RTLM8b1
- liveMap.data[mapRemove.key] = LiveMapEntry(
- isTombstoned = true, // RTLM8b2
- tombstonedAt = tombstonedAt,
- timeserial = timeSerial
- )
- }
-
- return LiveMapUpdate(mapOf(mapRemove.key to LiveMapUpdate.Change.REMOVED))
- }
-
- /**
- * @spec RTLM24 - Applies MAP_CLEAR operation to LiveMap
- */
- private fun applyMapClear(timeSerial: String?): LiveMapUpdate {
- val clearSerial = liveMap.clearTimeserial
-
- // RTLM24c - skip if existing clear serial is strictly newer than incoming op serial
- if (clearSerial != null && (timeSerial == null || clearSerial > timeSerial)) {
- Log.v(tag,
- "Skipping MAP_CLEAR: op serial $timeSerial <= current clear serial $clearSerial; objectId=$objectId")
- return noOpMapUpdate
- }
-
- Log.v(tag,
- "Updating clearTimeserial; previous=$clearSerial, new=$timeSerial; objectId=$objectId")
- liveMap.clearTimeserial = timeSerial // RTLM24d
-
- val update = mutableMapOf()
-
- // RTLM24e - remove all entries whose serial is older than (or equal to missing) the clear serial
- liveMap.data.entries.removeIf {
- val (key, entry) = it
- val entrySerial = entry.timeserial
- if (entrySerial == null || (timeSerial != null && timeSerial > entrySerial)) {
- update[key] = LiveMapUpdate.Change.REMOVED
- true
- } else {
- false
- }
- }
-
- return LiveMapUpdate(update)
- }
-
- /**
- * For Lww CRDT semantics (the only supported LiveMap semantic) an operation
- * Should only be applied if incoming serial is strictly greater than existing entry's serial.
- * @spec RTLM9 - Serial comparison logic for map operations
- */
- private fun canApplyMapOperation(existingMapEntrySerial: String?, timeSerial: String?): Boolean {
- if (existingMapEntrySerial.isNullOrEmpty() && timeSerial.isNullOrEmpty()) { // RTLM9b
- return false
- }
- if (existingMapEntrySerial.isNullOrEmpty()) { // RTLM9d - If true, means timeSerial is not empty based on previous checks
- return true
- }
- if (timeSerial.isNullOrEmpty()) { // RTLM9c - Check reached here means existingMapEntrySerial is not empty
- return false
- }
- return timeSerial > existingMapEntrySerial // RTLM9e - both are not empty
- }
-
- /**
- * @spec RTLM23 - Merges initial data from create operation
- */
- private fun getEffectiveMapCreate(operation: ObjectOperation): MapCreate? =
- operation.mapCreateWithObjectId?.derivedFrom ?: operation.mapCreate
-
- private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): LiveMapUpdate {
- val effectiveMapCreate = getEffectiveMapCreate(operation)
- if (effectiveMapCreate?.entries.isNullOrEmpty()) { // no map entries in MAP_CREATE op
- return noOpMapUpdate
- }
-
- val aggregatedUpdate = mutableListOf()
-
- // RTLM23a
- // in order to apply MAP_CREATE op for an existing map, we should merge their underlying entries keys.
- // we can do this by iterating over entries from MAP_CREATE op and apply changes on per-key basis as if we had MAP_SET, MAP_REMOVE operations.
- effectiveMapCreate?.entries?.forEach { (key, entry) ->
- // for a MAP_CREATE operation we must use the serial value available on an entry, instead of a serial on a message
- val opTimeserial = entry.timeserial
- val update = if (entry.tombstone == true) {
- // RTLM23a2 - entry in MAP_CREATE op is removed, try to apply MAP_REMOVE op
- applyMapRemove(MapRemove(key), opTimeserial, entry.serialTimestamp)
- } else {
- // RTLM23a1 - entry in MAP_CREATE op is not removed, try to set it via MAP_SET op
- applyMapSet(MapSet(key, entry.data ?: throw objectError("MAP_SET operation without data")), opTimeserial)
- }
-
- // skip noop updates
- if (update.noOp) {
- return@forEach
- }
-
- aggregatedUpdate.add(update)
- }
-
- liveMap.createOperationIsMerged = true // RTLM23b
-
- return LiveMapUpdate(
- aggregatedUpdate.map { it.update }.fold(emptyMap()) { acc, map -> acc + map }
- )
- }
-
- internal fun calculateUpdateFromDataDiff(
- prevData: Map,
- newData: Map
- ): LiveMapUpdate {
- val update = mutableMapOf()
-
- // Check for removed entries
- for ((key, prevEntry) in prevData) {
- if (!prevEntry.isTombstoned && !newData.containsKey(key)) {
- update[key] = LiveMapUpdate.Change.REMOVED
- }
- }
-
- // Check for added/updated entries
- for ((key, newEntry) in newData) {
- if (!prevData.containsKey(key)) {
- // if property does not exist in current map, but new data has it as non-tombstoned property - got updated
- if (!newEntry.isTombstoned) {
- update[key] = LiveMapUpdate.Change.UPDATED
- }
- // otherwise, if new data has this prop tombstoned - do nothing, as property didn't exist anyway
- continue
- }
-
- // properties that exist both in current and new map data need to have their values compared to decide on update type
- val prevEntry = prevData[key]!!
-
- // compare tombstones first
- if (prevEntry.isTombstoned && !newEntry.isTombstoned) {
- // prev prop is tombstoned, but new is not. it means prop was updated to a meaningful value
- update[key] = LiveMapUpdate.Change.UPDATED
- continue
- }
- if (!prevEntry.isTombstoned && newEntry.isTombstoned) {
- // prev prop is not tombstoned, but new is. it means prop was removed
- update[key] = LiveMapUpdate.Change.REMOVED
- continue
- }
- if (prevEntry.isTombstoned && newEntry.isTombstoned) {
- // props are tombstoned - treat as noop, as there is no data to compare
- continue
- }
-
- // both props exist and are not tombstoned, need to compare values to see if it was changed
- val valueChanged = prevEntry.data != newEntry.data
- if (valueChanged) {
- update[key] = LiveMapUpdate.Change.UPDATED
- continue
- }
- }
-
- return LiveMapUpdate(update)
- }
-
- internal fun validate(state: ObjectState) {
- liveMap.validateObjectId(state.objectId)
- validateMapSemantics(state.map?.semantics)
- state.createOp?.let { createOp ->
- liveMap.validateObjectId(createOp.objectId)
- validateMapCreateAction(createOp.action)
- validateMapSemantics(getEffectiveMapCreate(createOp)?.semantics)
- }
- }
-
- private fun validateMapCreateAction(action: ObjectOperationAction) {
- if (action != ObjectOperationAction.MapCreate) {
- throw objectError("Invalid create operation action $action for LiveMap objectId=${objectId}")
- }
- }
-
- private fun validateMapSemantics(semantics: ObjectsMapSemantics?) {
- if (semantics != liveMap.semantics) {
- throw objectError(
- "Invalid object: incoming object map semantics=$semantics; current map semantics=${ObjectsMapSemantics.LWW}"
- )
- }
- }
-}
diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/TestUtils.kt
similarity index 98%
rename from liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt
rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/TestUtils.kt
index a91f0e9cf..65c712463 100644
--- a/liveobjects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt
+++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/TestUtils.kt
@@ -1,4 +1,4 @@
-package io.ably.lib.objects
+package io.ably.lib.liveobjects
import java.lang.reflect.Field
import kotlinx.coroutines.Dispatchers
diff --git a/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/DefaultRealtimeObjectTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/DefaultRealtimeObjectTest.kt
new file mode 100644
index 000000000..7fc44f60a
--- /dev/null
+++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/DefaultRealtimeObjectTest.kt
@@ -0,0 +1,41 @@
+package io.ably.lib.liveobjects.integration
+
+import io.ably.lib.liveobjects.assertWaiter
+import io.ably.lib.liveobjects.integration.setup.IntegrationTest
+import io.ably.lib.realtime.ChannelState
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+/**
+ * Basic integration tests for the path-based LiveObjects implementation.
+ *
+ * These exercise the sandbox setup/teardown (see [IntegrationTest]) together with the
+ * realtime connection and channel lifecycle against a real sandbox app. The path-based
+ * public Objects API (`channel.object`) is not yet wired to the plugin on this branch -
+ * it currently resolves to the `RealtimeObject.Unavailable` null-object guard - so these
+ * tests assert connectivity and the always-present `object` accessor rather than object
+ * functionality. Functional object tests will be added as the implementation lands.
+ */
+class DefaultRealtimeObjectTest : IntegrationTest() {
+
+ @Test
+ fun testRealtimeChannelAttachesOnSandbox() = runTest {
+ val channelName = generateChannelName()
+ val channel = getRealtimeChannel(channelName)
+ assertNotNull(channel)
+
+ channel.attach()
+ assertWaiter { channel.state == ChannelState.attached }
+ assertEquals(ChannelState.attached, channel.state)
+ }
+
+ @Test
+ fun testRealtimeChannelExposesObjectAccessor() = runTest {
+ val channelName = generateChannelName()
+ val channel = getRealtimeChannel(channelName)
+ // `channel.object` is always non-null (null-object guard) even without the plugin installed.
+ assertNotNull(channel.`object`)
+ }
+}
diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/PayloadBuilder.kt
similarity index 72%
rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt
rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/PayloadBuilder.kt
index 283d11a4f..2f025059c 100644
--- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt
+++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/PayloadBuilder.kt
@@ -1,10 +1,10 @@
-package io.ably.lib.objects.integration.helpers
+package io.ably.lib.liveobjects.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 io.ably.lib.liveobjects.generateNonce
+import io.ably.lib.liveobjects.message.WireObjectData
+import io.ably.lib.liveobjects.message.WireObjectOperationAction
+import io.ably.lib.liveobjects.serialization.gson
internal object PayloadBuilder {
/**
@@ -12,11 +12,11 @@ internal object PayloadBuilder {
* Maps ObjectOperationAction enum values to their string representations.
*/
private val ACTION_STRINGS = mapOf(
- ObjectOperationAction.MapCreate to "MAP_CREATE",
- ObjectOperationAction.MapSet to "MAP_SET",
- ObjectOperationAction.MapRemove to "MAP_REMOVE",
- ObjectOperationAction.CounterCreate to "COUNTER_CREATE",
- ObjectOperationAction.CounterInc to "COUNTER_INC",
+ WireObjectOperationAction.MapCreate to "MAP_CREATE",
+ WireObjectOperationAction.MapSet to "MAP_SET",
+ WireObjectOperationAction.MapRemove to "MAP_REMOVE",
+ WireObjectOperationAction.CounterCreate to "COUNTER_CREATE",
+ WireObjectOperationAction.CounterInc to "COUNTER_INC",
)
/**
@@ -28,11 +28,11 @@ internal object PayloadBuilder {
*/
internal fun mapCreateRestOp(
objectId: String? = null,
- data: Map? = null,
+ data: Map? = null,
nonce: String? = null,
): JsonObject {
val opBody = JsonObject().apply {
- addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapCreate])
+ addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.MapCreate])
}
if (data != null) {
@@ -51,9 +51,9 @@ internal object PayloadBuilder {
/**
* Creates a MAP_SET operation payload for REST API.
*/
- internal fun mapSetRestOp(objectId: String, key: String, value: ObjectData): JsonObject {
+ internal fun mapSetRestOp(objectId: String, key: String, value: WireObjectData): JsonObject {
val opBody = JsonObject().apply {
- addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapSet])
+ addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.MapSet])
addProperty("objectId", objectId)
}
@@ -71,7 +71,7 @@ internal object PayloadBuilder {
*/
internal fun mapRemoveRestOp(objectId: String, key: String): JsonObject {
val opBody = JsonObject().apply {
- addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapRemove])
+ addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.MapRemove])
addProperty("objectId", objectId)
}
@@ -96,7 +96,7 @@ internal object PayloadBuilder {
nonce: String? = null,
): JsonObject {
val opBody = JsonObject().apply {
- addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterCreate])
+ addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.CounterCreate])
}
if (number != null) {
@@ -119,7 +119,7 @@ internal object PayloadBuilder {
*/
internal fun counterIncRestOp(objectId: String, number: Double): JsonObject {
val opBody = JsonObject().apply {
- addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterInc])
+ addProperty("operation", ACTION_STRINGS[WireObjectOperationAction.CounterInc])
addProperty("objectId", objectId)
add("data", JsonObject().apply {
addProperty("number", number)
diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/RestObjects.kt
similarity index 94%
rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt
rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/RestObjects.kt
index d06559377..62ef8d7d5 100644
--- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt
+++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/RestObjects.kt
@@ -1,10 +1,10 @@
-package io.ably.lib.objects.integration.helpers
+package io.ably.lib.liveobjects.integration.helpers
import com.google.gson.JsonObject
-import io.ably.lib.objects.ObjectData
+import io.ably.lib.liveobjects.message.WireObjectData
import io.ably.lib.rest.AblyRest
import io.ably.lib.http.HttpUtils
-import io.ably.lib.objects.integration.helpers.fixtures.DataFixtures
+import io.ably.lib.liveobjects.integration.helpers.fixtures.DataFixtures
import io.ably.lib.types.ClientOptions
/**
@@ -18,7 +18,7 @@ internal class RestObjects(options: ClientOptions) {
* Creates a new map object on the channel with optional initial data.
* @return The object ID of the created map
*/
- internal fun createMap(channelName: String, data: Map? = null): String {
+ internal fun createMap(channelName: String, data: Map? = null): String {
val mapCreateOp = PayloadBuilder.mapCreateRestOp(data = data)
return operationRequest(channelName, mapCreateOp).objectId ?:
throw Exception("Failed to create map: no objectId returned")
@@ -27,7 +27,7 @@ internal class RestObjects(options: ClientOptions) {
/**
* Sets a value (primitives, JsonObject, JsonArray, etc.) at the specified key in an existing map.
*/
- internal fun setMapValue(channelName: String, mapObjectId: String, key: String, data: ObjectData) {
+ internal fun setMapValue(channelName: String, mapObjectId: String, key: String, data: WireObjectData) {
val mapCreateOp = PayloadBuilder.mapSetRestOp(mapObjectId, key, data)
operationRequest(channelName, mapCreateOp)
}
diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/CounterFixtures.kt
similarity index 96%
rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt
rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/CounterFixtures.kt
index a8135a9e4..86f454d41 100644
--- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt
+++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/CounterFixtures.kt
@@ -1,6 +1,6 @@
-package io.ably.lib.objects.integration.helpers.fixtures
+package io.ably.lib.liveobjects.integration.helpers.fixtures
-import io.ably.lib.objects.integration.helpers.RestObjects
+import io.ably.lib.liveobjects.integration.helpers.RestObjects
/**
* Creates a comprehensive test fixture object tree focused on user-context counters.
diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/DataFixtures.kt
similarity index 65%
rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt
rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/DataFixtures.kt
index f6f305aba..57227dc7e 100644
--- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt
+++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/DataFixtures.kt
@@ -1,48 +1,48 @@
-package io.ably.lib.objects.integration.helpers.fixtures
+package io.ably.lib.liveobjects.integration.helpers.fixtures
import com.google.gson.JsonArray
import com.google.gson.JsonObject
-import io.ably.lib.objects.ObjectData
+import io.ably.lib.liveobjects.message.WireObjectData
import java.util.Base64
internal object DataFixtures {
/** Test fixture for string value ("stringValue") data type */
- internal val stringData = ObjectData(string = "stringValue")
+ internal val stringData = WireObjectData(string = "stringValue")
/** Test fixture for empty string data type */
- internal val emptyStringData = ObjectData(string = "")
+ internal val emptyStringData = WireObjectData(string = "")
/** Test fixture for binary data containing encoded JSON */
- internal val bytesData = ObjectData(
+ internal val bytesData = WireObjectData(
bytes = Base64.getEncoder().encodeToString("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray()))
/** Test fixture for empty binary data (zero-length byte array) */
- internal val emptyBytesData = ObjectData(bytes = Base64.getEncoder().encodeToString(ByteArray(0)))
+ internal val emptyBytesData = WireObjectData(bytes = Base64.getEncoder().encodeToString(ByteArray(0)))
/** Test fixture for maximum safe number value */
- internal val maxSafeNumberData = ObjectData(number = 99999999.0)
+ internal val maxSafeNumberData = WireObjectData(number = 99999999.0)
/** Test fixture for minimum safe number value */
- internal val negativeMaxSafeNumberData = ObjectData(number = -99999999.0)
+ internal val negativeMaxSafeNumberData = WireObjectData(number = -99999999.0)
/** Test fixture for positive number value (1) */
- internal val numberData = ObjectData(number = 1.0)
+ internal val numberData = WireObjectData(number = 1.0)
/** Test fixture for zero number value */
- internal val zeroData = ObjectData(number = 0.0)
+ internal val zeroData = WireObjectData(number = 0.0)
/** Test fixture for boolean true value */
- internal val trueData = ObjectData(boolean = true)
+ internal val trueData = WireObjectData(boolean = true)
/** Test fixture for boolean false value */
- internal val falseData = ObjectData(boolean = false)
+ internal val falseData = WireObjectData(boolean = false)
/** Test fixture for JSON object value with single property */
- internal val objectData = ObjectData(json = JsonObject().apply { addProperty("foo", "bar") })
+ internal val objectData = WireObjectData(json = JsonObject().apply { addProperty("foo", "bar") })
/** Test fixture for JSON array value with three string elements */
- internal val arrayData = ObjectData(
+ internal val arrayData = WireObjectData(
json = JsonArray().apply {
add("foo")
add("bar")
@@ -54,13 +54,13 @@ internal object DataFixtures {
* Creates an ObjectData instance that references another map object.
* @param referencedMapObjectId The object ID of the referenced map
*/
- internal fun mapRef(referencedMapObjectId: String) = ObjectData(objectId = referencedMapObjectId)
+ internal fun mapRef(referencedMapObjectId: String) = WireObjectData(objectId = referencedMapObjectId)
/**
* Creates a test fixture map containing all supported data types and values.
* @param referencedMapObjectId The object ID to be used for the map reference entry
*/
- internal fun mapWithAllValues(referencedMapObjectId: String? = null): Map {
+ internal fun mapWithAllValues(referencedMapObjectId: String? = null): Map {
val baseMap = mapOf(
"string" to stringData,
"emptyString" to emptyStringData,
diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/MapFixtures.kt
similarity index 89%
rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt
rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/MapFixtures.kt
index 475bbe86a..2d92233e2 100644
--- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt
+++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/helpers/fixtures/MapFixtures.kt
@@ -1,7 +1,7 @@
-package io.ably.lib.objects.integration.helpers.fixtures
+package io.ably.lib.liveobjects.integration.helpers.fixtures
-import io.ably.lib.objects.ObjectData
-import io.ably.lib.objects.integration.helpers.RestObjects
+import io.ably.lib.liveobjects.message.WireObjectData
+import io.ably.lib.liveobjects.integration.helpers.RestObjects
/**
* Initializes a comprehensive test fixture object tree on the specified channel.
@@ -114,10 +114,10 @@ internal fun RestObjects.createUserMapObject(channelName: String): String {
val preferencesMapObjectId = createMap(
channelName,
data = mapOf(
- "theme" to ObjectData(string = "dark"),
- "notifications" to ObjectData(boolean = true),
- "language" to ObjectData(string = "en"),
- "maxRetries" to ObjectData(number = 3.0)
+ "theme" to WireObjectData(string = "dark"),
+ "notifications" to WireObjectData(boolean = true),
+ "language" to WireObjectData(string = "en"),
+ "maxRetries" to WireObjectData(number = 3.0)
)
)
@@ -127,8 +127,8 @@ internal fun RestObjects.createUserMapObject(channelName: String): String {
data = mapOf(
"totalLogins" to DataFixtures.mapRef(loginCounterObjectId),
"activeSessions" to DataFixtures.mapRef(sessionCounterObjectId),
- "lastLoginTime" to ObjectData(string = "2024-01-01T08:30:00Z"),
- "profileViews" to ObjectData(number = 42.0)
+ "lastLoginTime" to WireObjectData(string = "2024-01-01T08:30:00Z"),
+ "profileViews" to WireObjectData(number = 42.0)
)
)
@@ -174,10 +174,10 @@ internal fun RestObjects.createUserProfileMapObject(channelName: String): String
return createMap(
channelName,
data = mapOf(
- "userId" to ObjectData(string = "user123"),
- "name" to ObjectData(string = "John Doe"),
- "email" to ObjectData(string = "john@example.com"),
- "isActive" to ObjectData(boolean = true),
+ "userId" to WireObjectData(string = "user123"),
+ "name" to WireObjectData(string = "John Doe"),
+ "email" to WireObjectData(string = "john@example.com"),
+ "isActive" to WireObjectData(boolean = true),
)
)
}
diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/IntegrationTest.kt
similarity index 96%
rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt
rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/IntegrationTest.kt
index cb46f2f89..1f0a3dba1 100644
--- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt
+++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/IntegrationTest.kt
@@ -1,6 +1,6 @@
-package io.ably.lib.objects.integration.setup
+package io.ably.lib.liveobjects.integration.setup
-import io.ably.lib.objects.integration.helpers.RestObjects
+import io.ably.lib.liveobjects.integration.helpers.RestObjects
import io.ably.lib.realtime.AblyRealtime
import io.ably.lib.realtime.Channel
import io.ably.lib.types.ChannelMode
diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt
similarity index 94%
rename from liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt
rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt
index cfcd4ed2b..5cc2f9360 100644
--- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt
+++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/integration/setup/Sandbox.kt
@@ -1,9 +1,9 @@
-package io.ably.lib.objects.integration.setup
+package io.ably.lib.liveobjects.integration.setup
import com.google.gson.JsonElement
import com.google.gson.JsonParser
-import io.ably.lib.objects.ablyException
-import io.ably.lib.objects.integration.helpers.RestObjects
+import io.ably.lib.liveobjects.ablyException
+import io.ably.lib.liveobjects.integration.helpers.RestObjects
import io.ably.lib.realtime.*
import io.ably.lib.types.ClientOptions
import io.ktor.client.*
diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/HelpersTest.kt
similarity index 84%
rename from liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt
rename to liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/HelpersTest.kt
index 21f5c6792..76c37d21d 100644
--- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt
+++ b/liveobjects/src/test/kotlin/io/ably/lib/liveobjects/unit/HelpersTest.kt
@@ -1,6 +1,10 @@
-package io.ably.lib.objects.unit
+package io.ably.lib.liveobjects.unit
-import io.ably.lib.objects.*
+import io.ably.lib.liveobjects.*
+import io.ably.lib.liveobjects.adapter.AblyClientAdapter
+import io.ably.lib.liveobjects.clientError
+import io.ably.lib.liveobjects.connectionManager
+import io.ably.lib.liveobjects.sendAsync
import io.ably.lib.realtime.Channel
import io.ably.lib.realtime.ChannelState
import io.ably.lib.realtime.ChannelStateListener
@@ -19,7 +23,7 @@ class HelpersTest {
// sendAsync
@Test
fun testSendAsyncShouldQueueAccordingToClientOptions() = runTest {
- val adapter = getMockObjectsAdapter()
+ val adapter = getMockAblyClientAdapter()
val connManager = adapter.connectionManager
val clientOptions = ClientOptions().apply { queueMessages = false }
@@ -41,7 +45,7 @@ class HelpersTest {
@Test
fun testSendAsyncErrorPropagatesAblyException() = runTest {
- val adapter = getMockObjectsAdapter()
+ val adapter = getMockAblyClientAdapter()
val connManager = adapter.connectionManager
val clientOptions = ClientOptions()
@@ -61,7 +65,7 @@ class HelpersTest {
@Test
fun testOnGCGracePeriodImmediateInvokesBlock() {
- val adapter = getMockObjectsAdapter()
+ val adapter = getMockAblyClientAdapter()
val connManager = adapter.connectionManager
connManager.setPrivateField("objectsGCGracePeriod", 123L)
@@ -74,7 +78,7 @@ class HelpersTest {
@Test
fun testOnGCGracePeriodDeferredInvokesOnConnectedWithValue() {
- val adapter = getMockObjectsAdapter()
+ val adapter = getMockAblyClientAdapter()
val connManager = adapter.connectionManager
val connection = adapter.connection
@@ -93,7 +97,7 @@ class HelpersTest {
@Test
fun testOnGCGracePeriodDeferredInvokesOnConnectedWithNull() {
- val adapter = getMockObjectsAdapter()
+ val adapter = getMockAblyClientAdapter()
val connection = adapter.connection
var value: Long? = null
@@ -110,7 +114,7 @@ class HelpersTest {
@Test
fun testSendAsyncThrowsWhenConnectionManagerThrows() = runTest {
- val adapter = getMockObjectsAdapter()
+ val adapter = getMockAblyClientAdapter()
val connManager = adapter.connectionManager
val clientOptions = ClientOptions()
@@ -127,7 +131,7 @@ class HelpersTest {
// attachAsync
@Test
fun testAttachAsyncSuccess() = runTest {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
every { channel.attach(any()) } answers {
@@ -141,7 +145,7 @@ class HelpersTest {
@Test
fun testAttachAsyncError() = runTest {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
every { channel.attach(any()) } answers {
@@ -157,7 +161,7 @@ class HelpersTest {
// getChannelModes
@Test
fun testGetChannelModesPrefersChannelModes() {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
every { channel.modes } returns arrayOf(ChannelMode.object_publish)
@@ -169,7 +173,7 @@ class HelpersTest {
@Test
fun testGetChannelModesFallsBackToOptions() {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
every { channel.modes } returns emptyArray()
@@ -181,7 +185,7 @@ class HelpersTest {
@Test
fun testGetChannelModesReturnsNullWhenNoModes() {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
every { channel.modes } returns null
@@ -193,7 +197,7 @@ class HelpersTest {
@Test
fun testGetChannelModesIgnoresEmptyModes() {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
every { channel.modes } returns emptyArray()
@@ -206,7 +210,7 @@ class HelpersTest {
// setChannelSerial
@Test
fun testSetChannelSerialSetsWhenObjectActionAndNonEmpty() {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
val props = ChannelProperties()
channel.properties = props
@@ -221,7 +225,7 @@ class HelpersTest {
@Test
fun testSetChannelSerialNoOpForNonObjectActionOrEmpty() {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
val props = ChannelProperties()
channel.properties = props
@@ -243,7 +247,7 @@ class HelpersTest {
// ensureAttached
@Test
fun testEnsureAttachedFromInitializedAttaches() = runTest {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
@@ -260,7 +264,7 @@ class HelpersTest {
@Test
fun testEnsureAttachedWhenAlreadyAttachedReturns() = runTest {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
channel.state = ChannelState.attached
@@ -272,7 +276,7 @@ class HelpersTest {
@Test
fun testEnsureAttachedWaitsForAttachingThenAttached() = runTest {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
channel.state = ChannelState.attaching
@@ -291,7 +295,7 @@ class HelpersTest {
@Test
fun testEnsureAttachedAttachingButReceivesNonAttachedEmitsError() = runTest {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
channel.state = ChannelState.attaching
@@ -304,37 +308,37 @@ class HelpersTest {
listener.onChannelStateChanged(stateChange)
}
val ex = assertFailsWith { adapter.ensureAttached("ch") }
- assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code)
+ assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code)
assertTrue(ex.errorInfo.message.contains("Not attached"))
verify(exactly = 1) { channel.once(any()) }
}
@Test
fun testEnsureAttachedThrowsForInvalidState() = runTest {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
channel.state = ChannelState.failed
val ex = assertFailsWith { adapter.ensureAttached("ch") }
- assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code)
+ assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code)
}
// throwIfInvalidAccessApiConfiguration
@Test
fun testThrowIfInvalidAccessApiConfigurationStateDetached() {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
channel.state = ChannelState.detached
val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") }
- assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code)
+ assertEquals(ObjectErrorCode.ChannelStateError.code, ex.errorInfo.code)
}
@Test
fun testThrowIfInvalidAccessApiConfigurationMissingMode() {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val channel = mockk(relaxed = true)
every { adapter.getChannel("ch") } returns channel
channel.state = ChannelState.attached
@@ -342,37 +346,37 @@ class HelpersTest {
every { channel.options } returns ChannelOptions().apply { modes = null }
val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") }
- assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code)
+ assertEquals(ObjectErrorCode.ChannelModeRequired.code, ex.errorInfo.code)
assertTrue(ex.errorInfo.message.contains("object_subscribe"))
}
// throwIfInvalidWriteApiConfiguration
@Test
fun testThrowIfInvalidWriteApiConfigurationEchoDisabled() {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk(relaxed = true)
val clientOptions = ClientOptions().apply { echoMessages = false }
every { adapter.clientOptions } returns clientOptions
val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") }
- assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code)
+ assertEquals(ObjectErrorCode.BadRequest.code, ex.errorInfo.code)
assertTrue(ex.errorInfo.message.contains("echoMessages"))
}
@Test
fun testThrowIfInvalidWriteApiConfigurationInvalidState() {
- val adapter = mockk(relaxed = true)
+ val adapter = mockk