diff --git a/lib/src/main/java/io/ably/lib/realtime/Presence.java b/lib/src/main/java/io/ably/lib/realtime/Presence.java
index 9a7e89e7e..f795a6a8a 100644
--- a/lib/src/main/java/io/ably/lib/realtime/Presence.java
+++ b/lib/src/main/java/io/ably/lib/realtime/Presence.java
@@ -10,6 +10,7 @@
import io.ably.lib.types.Callback;
import io.ably.lib.types.ErrorInfo;
import io.ably.lib.types.MessageDecodeException;
+import io.ably.lib.types.MessageExtras;
import io.ably.lib.types.PaginatedResult;
import io.ably.lib.types.Param;
import io.ably.lib.types.PresenceMessage;
@@ -483,8 +484,27 @@ private void unsubscribeImpl(PresenceMessage.Action action, PresenceListener lis
* @throws AblyException
*/
public void enter(Object data, CompletionListener listener) throws AblyException {
+ enter(data, null, listener);
+ }
+
+ /**
+ * Enters the presence set for the channel, optionally passing a data payload and extras.
+ * A clientId is required to be present on a channel.
+ * An optional callback may be provided to notify of the success or failure of the operation.
+ *
+ *
+ * Spec: RTP8
+ *
+ * @param data The payload associated with the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener A callback to notify of the success or failure of the operation.
+ *
+ * This listener is invoked on a background thread.
+ * @throws AblyException
+ */
+ public void enter(Object data, MessageExtras extras, CompletionListener listener) throws AblyException {
Log.v(TAG, "enter(); channel = " + channel.name);
- updatePresence(new PresenceMessage(PresenceMessage.Action.enter, null, data), listener);
+ updatePresence(new PresenceMessage(PresenceMessage.Action.enter, null, data, extras), listener);
}
/**
@@ -502,8 +522,27 @@ public void enter(Object data, CompletionListener listener) throws AblyException
* @throws AblyException
*/
public void update(Object data, CompletionListener listener) throws AblyException {
+ update(data, null, listener);
+ }
+
+ /**
+ * Updates the data payload for a presence member, optionally passing extras.
+ * If called before entering the presence set, this is treated as an {@link PresenceMessage.Action#enter} event.
+ * An optional callback may be provided to notify of the success or failure of the operation.
+ *
+ *
+ * Spec: RTP9
+ *
+ * @param data The payload associated with the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener A callback to notify of the success or failure of the operation.
+ *
+ * This listener is invoked on a background thread.
+ * @throws AblyException
+ */
+ public void update(Object data, MessageExtras extras, CompletionListener listener) throws AblyException {
Log.v(TAG, "update(); channel = " + channel.name);
- updatePresence(new PresenceMessage(PresenceMessage.Action.update, null, data), listener);
+ updatePresence(new PresenceMessage(PresenceMessage.Action.update, null, data, extras), listener);
}
/**
@@ -520,8 +559,26 @@ public void update(Object data, CompletionListener listener) throws AblyExceptio
* @throws AblyException
*/
public void leave(Object data, CompletionListener listener) throws AblyException {
+ leave(data, null, listener);
+ }
+
+ /**
+ * Leaves the presence set for the channel, optionally passing extras.
+ * A client must have previously entered the presence set before they can leave it.
+ *
+ *
+ * Spec: RTP10
+ *
+ * @param data The payload associated with the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener a listener to notify of the success or failure of the operation.
+ *
+ * This listener is invoked on a background thread.
+ * @throws AblyException
+ */
+ public void leave(Object data, MessageExtras extras, CompletionListener listener) throws AblyException {
Log.v(TAG, "leave(); channel = " + channel.name);
- updatePresence(new PresenceMessage(PresenceMessage.Action.leave, null, data), listener);
+ updatePresence(new PresenceMessage(PresenceMessage.Action.leave, null, data, extras), listener);
}
/**
@@ -584,6 +641,25 @@ public void enterClient(String clientId, Object data) throws AblyException {
* This listener is invoked on a background thread.
*/
public void enterClient(String clientId, Object data, CompletionListener listener) throws AblyException {
+ enterClient(clientId, data, null, listener);
+ }
+
+ /**
+ * Enters the presence set of the channel for a given clientId, optionally passing extras.
+ * Enables a single client to update presence on behalf of any number of clients using a single connection.
+ * The library must have been instantiated with an API key or a token bound to a wildcard clientId.
+ *
+ *
+ * Spec: RTP4, RTP14, RTP15
+ *
+ * @param clientId The ID of the client to enter into the presence set.
+ * @param data The payload associated with the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener A callback to notify of the success or failure of the operation.
+ *
+ * This listener is invoked on a background thread.
+ */
+ public void enterClient(String clientId, Object data, MessageExtras extras, CompletionListener listener) throws AblyException {
if(clientId == null) {
String errorMessage = String.format(Locale.ROOT, "Channel %s: unable to enter presence channel (null clientId specified)", channel.name);
Log.v(TAG, errorMessage);
@@ -593,7 +669,7 @@ public void enterClient(String clientId, Object data, CompletionListener listene
}
}
Log.v(TAG, "enterClient(); channel = " + channel.name + "; clientId = " + clientId);
- updatePresence(new PresenceMessage(PresenceMessage.Action.enter, clientId, data), listener);
+ updatePresence(new PresenceMessage(PresenceMessage.Action.enter, clientId, data, extras), listener);
}
private void enterClientWithId(String id, String clientId, Object data, CompletionListener listener) throws AblyException {
@@ -658,6 +734,26 @@ public void updateClient(String clientId, Object data) throws AblyException {
* This listener is invoked on a background thread.
*/
public void updateClient(String clientId, Object data, CompletionListener listener) throws AblyException {
+ updateClient(clientId, data, null, listener);
+ }
+
+ /**
+ * Updates the data payload for a presence member using a given clientId, optionally passing extras.
+ * Enables a single client to update presence on behalf of any number of clients using a single connection.
+ * The library must have been instantiated with an API key or a token bound to a wildcard clientId.
+ * An optional callback may be provided to notify of the success or failure of the operation.
+ *
+ *
+ * Spec: RTP15
+ *
+ * @param clientId The ID of the client to update in the presence set.
+ * @param data The payload to update for the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener A callback to notify of the success or failure of the operation.
+ *
+ * This listener is invoked on a background thread.
+ */
+ public void updateClient(String clientId, Object data, MessageExtras extras, CompletionListener listener) throws AblyException {
if(clientId == null) {
String errorMessage = String.format(Locale.ROOT, "Channel %s: unable to update presence channel (null clientId specified)", channel.name);
Log.v(TAG, errorMessage);
@@ -667,7 +763,7 @@ public void updateClient(String clientId, Object data, CompletionListener listen
}
}
Log.v(TAG, "updateClient(); channel = " + channel.name + "; clientId = " + clientId);
- updatePresence(new PresenceMessage(PresenceMessage.Action.update, clientId, data), listener);
+ updatePresence(new PresenceMessage(PresenceMessage.Action.update, clientId, data, extras), listener);
}
/**
@@ -714,6 +810,25 @@ public void leaveClient(String clientId, Object data) throws AblyException {
* This listener is invoked on a background thread.
*/
public void leaveClient(String clientId, Object data, CompletionListener listener) throws AblyException {
+ leaveClient(clientId, data, null, listener);
+ }
+
+ /**
+ * Leaves the presence set of the channel for a given clientId, optionally passing extras.
+ * Enables a single client to update presence on behalf of any number of clients using a single connection.
+ * The library must have been instantiated with an API key or a token bound to a wildcard clientId.
+ *
+ *
+ * Spec: RTP15
+ *
+ * @param clientId The ID of the client to leave the presence set for.
+ * @param data The payload associated with the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener A callback to notify of the success or failure of the operation.
+ *
+ * This listener is invoked on a background thread.
+ */
+ public void leaveClient(String clientId, Object data, MessageExtras extras, CompletionListener listener) throws AblyException {
if(clientId == null) {
String errorMessage = String.format(Locale.ROOT, "Channel %s: unable to leave presence channel (null clientId specified)", channel.name);
Log.v(TAG, errorMessage);
@@ -723,7 +838,7 @@ public void leaveClient(String clientId, Object data, CompletionListener listene
}
}
Log.v(TAG, "leaveClient(); channel = " + channel.name + "; clientId = " + clientId);
- updatePresence(new PresenceMessage(PresenceMessage.Action.leave, clientId, data), listener);
+ updatePresence(new PresenceMessage(PresenceMessage.Action.leave, clientId, data, extras), listener);
}
/**
diff --git a/lib/src/main/java/io/ably/lib/types/PresenceMessage.java b/lib/src/main/java/io/ably/lib/types/PresenceMessage.java
index d3073a879..2ba220ba0 100644
--- a/lib/src/main/java/io/ably/lib/types/PresenceMessage.java
+++ b/lib/src/main/java/io/ably/lib/types/PresenceMessage.java
@@ -75,6 +75,16 @@ public enum Action {
*/
public Action action;
+ /**
+ * A MessageExtras object of arbitrary key-value pairs that may contain metadata, and/or ancillary payloads.
+ * Valid payloads include {@link DeltaExtras}, {@link JsonObject}.
+ *
+ * Spec: TP3i
+ */
+ public MessageExtras extras;
+
+ private static final String EXTRAS = "extras";
+
/**
* Default constructor
*/
@@ -96,9 +106,21 @@ public PresenceMessage(Action action, String clientId) {
* @param data
*/
public PresenceMessage(Action action, String clientId, Object data) {
+ this(action, clientId, data, null);
+ }
+
+ /**
+ * Construct a PresenceMessage with extras
+ * @param action
+ * @param clientId
+ * @param data
+ * @param extras
+ */
+ public PresenceMessage(Action action, String clientId, Object data, MessageExtras extras) {
this.action = action;
this.clientId = clientId;
this.data = data;
+ this.extras = extras;
}
/**
@@ -123,16 +145,22 @@ public Object clone() {
result.encoding = encoding;
result.data = data;
result.action = action;
+ result.extras = extras;
return result;
}
void writeMsgpack(MessagePacker packer) throws IOException {
int fieldCount = super.countFields();
++fieldCount;
+ if(extras != null) ++fieldCount;
packer.packMapHeader(fieldCount);
super.writeFields(packer);
packer.packString("action");
packer.packInt(action.getValue());
+ if(extras != null) {
+ packer.packString(EXTRAS);
+ extras.write(packer);
+ }
}
PresenceMessage readMsgpack(MessageUnpacker unpacker) throws IOException {
@@ -145,6 +173,8 @@ PresenceMessage readMsgpack(MessageUnpacker unpacker) throws IOException {
if(super.readField(unpacker, fieldName, fieldFormat)) { continue; }
if(fieldName.equals("action")) {
action = Action.findByValue(unpacker.unpackInt());
+ } else if (fieldName.equals(EXTRAS)) {
+ extras = MessageExtras.read(unpacker);
} else {
Log.v(TAG, "Unexpected field: " + fieldName);
unpacker.skipValue();
@@ -260,6 +290,24 @@ public static PresenceMessage[] fromEncodedArray(String presenceMsgArray, Channe
}
}
+ @Override
+ protected void read(final JsonObject map) throws MessageDecodeException {
+ super.read(map);
+
+ final JsonElement extrasElement = map.get(EXTRAS);
+ if (extrasElement != null && !extrasElement.isJsonNull()) {
+ if (!extrasElement.isJsonObject()) {
+ throw MessageDecodeException.fromDescription("PresenceMessage extras is of type \"" + extrasElement.getClass() + "\" when expected a JSON object.");
+ }
+ extras = MessageExtras.read(extrasElement.getAsJsonObject());
+ }
+
+ Integer actionValue = readInt(map, "action");
+ if (actionValue != null) {
+ action = Action.findByValue(actionValue);
+ }
+ }
+
public static class ActionSerializer implements JsonDeserializer {
@Override
public Action deserialize(JsonElement json, Type t, JsonDeserializationContext ctx)
@@ -268,13 +316,32 @@ public Action deserialize(JsonElement json, Type t, JsonDeserializationContext c
}
}
- public static class Serializer implements JsonSerializer {
+ public static class Serializer implements JsonSerializer, JsonDeserializer {
@Override
public JsonElement serialize(PresenceMessage message, Type typeOfMessage, JsonSerializationContext ctx) {
final JsonObject json = BaseMessage.toJsonObject(message);
if(message.action != null) json.addProperty("action", message.action.getValue());
+ if(message.extras != null) {
+ json.add(EXTRAS, Serialisation.gson.toJsonTree(message.extras));
+ }
return json;
}
+
+ @Override
+ public PresenceMessage deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
+ if (!(json instanceof JsonObject)) {
+ throw new JsonParseException("Expected an object but got \"" + json.getClass() + "\".");
+ }
+
+ final PresenceMessage message = new PresenceMessage();
+ try {
+ message.read((JsonObject) json);
+ } catch (MessageDecodeException e) {
+ Log.e(TAG, e.getMessage(), e);
+ throw new JsonParseException("Failed to deserialize PresenceMessage from JSON.", e);
+ }
+ return message;
+ }
}
/**
diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceTest.java
index 2534139bd..0ed6f10e3 100644
--- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceTest.java
+++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimePresenceTest.java
@@ -3625,6 +3625,719 @@ public void messages_from_encoded_json_array() throws AblyException {
}
}
+ /**
+ * Enter presence with extras field and verify it comes back on the other side
+ * Test TP3i
+ */
+ @Test
+ public void presence_enter_with_extras() {
+ AblyRealtime clientAbly1 = null;
+ TestChannel testChannel = new TestChannel();
+ try {
+ /* subscribe for presence events in the anonymous connection */
+ PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel);
+ /* set up a connection with specific clientId */
+ ClientOptions client1Opts = new ClientOptions() {{
+ tokenDetails = token1;
+ clientId = testClientId1;
+ }};
+ fillInOptions(client1Opts);
+ clientAbly1 = new AblyRealtime(client1Opts);
+
+ /* wait until connected */
+ (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected);
+ assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected);
+
+ /* get channel and attach */
+ Channel client1Channel = clientAbly1.channels.get(testChannel.channelName);
+ client1Channel.attach();
+ (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached);
+ assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached);
+
+ /* create extras with headers.foo */
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("foo", "bar");
+ extrasJson.add("headers", headers);
+ io.ably.lib.types.MessageExtras extras = new io.ably.lib.types.MessageExtras(extrasJson);
+
+ /* create presence message with extras */
+ String enterString = "Test data (presence_enter_with_extras)";
+ PresenceMessage presenceMsg = new PresenceMessage(PresenceMessage.Action.enter, null, enterString);
+ presenceMsg.extras = extras;
+
+ /* let client1 enter the channel with extras and wait for the entered event to be delivered */
+ CompletionWaiter enterComplete = new CompletionWaiter();
+ client1Channel.presence.updatePresence(presenceMsg, enterComplete);
+ presenceWaiter.waitFor(testClientId1, Action.enter);
+ PresenceMessage receivedMessage = presenceWaiter.contains(testClientId1, Action.enter);
+ assertNotNull("Verify presence message received", receivedMessage);
+ assertEquals("Verify data matches", enterString, receivedMessage.data);
+
+ /* verify extras field is present and correct */
+ assertNotNull("Verify extras is not null", receivedMessage.extras);
+ JsonObject receivedExtrasJson = receivedMessage.extras.asJsonObject();
+ assertNotNull("Verify extras JSON is not null", receivedExtrasJson);
+ assertTrue("Verify headers exists in extras", receivedExtrasJson.has("headers"));
+ JsonObject receivedHeaders = receivedExtrasJson.getAsJsonObject("headers");
+ assertNotNull("Verify headers object is not null", receivedHeaders);
+ assertTrue("Verify foo exists in headers", receivedHeaders.has("foo"));
+ assertEquals("Verify foo value matches", "bar", receivedHeaders.get("foo").getAsString());
+
+ /* verify enter callback called on completion */
+ enterComplete.waitFor();
+ assertTrue("Verify enter callback called on completion", enterComplete.success);
+ } catch(AblyException e) {
+ e.printStackTrace();
+ fail("Unexpected exception running test: " + e.getMessage());
+ } finally {
+ if(clientAbly1 != null)
+ clientAbly1.close();
+ if(testChannel != null)
+ testChannel.dispose();
+ }
+ }
+
+ /**
+ * Enter presence using the convenience enter(data, extras, listener) method
+ * and verify extras come back on the subscriber side.
+ */
+ @Test
+ public void presence_enter_with_extras_convenience() {
+ AblyRealtime clientAbly1 = null;
+ TestChannel testChannel = new TestChannel();
+ try {
+ /* subscribe for presence events in the anonymous connection */
+ PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel);
+ /* set up a connection with specific clientId */
+ ClientOptions client1Opts = new ClientOptions() {{
+ tokenDetails = token1;
+ clientId = testClientId1;
+ }};
+ fillInOptions(client1Opts);
+ clientAbly1 = new AblyRealtime(client1Opts);
+
+ /* wait until connected */
+ (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected);
+ assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected);
+
+ /* get channel and attach */
+ Channel client1Channel = clientAbly1.channels.get(testChannel.channelName);
+ client1Channel.attach();
+ (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached);
+ assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached);
+
+ /* create extras with headers */
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("foo", "bar");
+ extrasJson.add("headers", headers);
+ io.ably.lib.types.MessageExtras extras = new io.ably.lib.types.MessageExtras(extrasJson);
+
+ /* enter using the convenience method with extras */
+ String enterString = "Test data (presence_enter_with_extras_convenience)";
+ CompletionWaiter enterComplete = new CompletionWaiter();
+ client1Channel.presence.enter(enterString, extras, enterComplete);
+ presenceWaiter.waitFor(testClientId1, Action.enter);
+ PresenceMessage receivedMessage = presenceWaiter.contains(testClientId1, Action.enter);
+ assertNotNull("Verify presence message received", receivedMessage);
+ assertEquals("Verify data matches", enterString, receivedMessage.data);
+
+ /* verify extras field is present and correct */
+ assertNotNull("Verify extras is not null", receivedMessage.extras);
+ JsonObject receivedExtrasJson = receivedMessage.extras.asJsonObject();
+ assertTrue("Verify headers exists in extras", receivedExtrasJson.has("headers"));
+ JsonObject receivedHeaders = receivedExtrasJson.getAsJsonObject("headers");
+ assertEquals("Verify foo value matches", "bar", receivedHeaders.get("foo").getAsString());
+
+ /* verify enter callback called on completion */
+ enterComplete.waitFor();
+ assertTrue("Verify enter callback called on completion", enterComplete.success);
+ } catch(AblyException e) {
+ e.printStackTrace();
+ fail("Unexpected exception running test: " + e.getMessage());
+ } finally {
+ if(clientAbly1 != null)
+ clientAbly1.close();
+ if(testChannel != null)
+ testChannel.dispose();
+ }
+ }
+
+ /**
+ * Update presence using the convenience update(data, extras, listener) method
+ * and verify extras come back on the subscriber side.
+ */
+ @Test
+ public void presence_update_with_extras() {
+ AblyRealtime clientAbly1 = null;
+ TestChannel testChannel = new TestChannel();
+ try {
+ /* subscribe for presence events in the anonymous connection */
+ PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel);
+ /* set up a connection with specific clientId */
+ ClientOptions client1Opts = new ClientOptions() {{
+ tokenDetails = token1;
+ clientId = testClientId1;
+ }};
+ fillInOptions(client1Opts);
+ clientAbly1 = new AblyRealtime(client1Opts);
+
+ /* wait until connected */
+ (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected);
+ assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected);
+
+ /* get channel and attach */
+ Channel client1Channel = clientAbly1.channels.get(testChannel.channelName);
+ client1Channel.attach();
+ (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached);
+ assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached);
+
+ /* enter first (no extras) */
+ CompletionWaiter enterComplete = new CompletionWaiter();
+ client1Channel.presence.enter("initial data", enterComplete);
+ enterComplete.waitFor();
+ presenceWaiter.waitFor(testClientId1, Action.enter);
+
+ /* create extras with headers */
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("key", "value");
+ extrasJson.add("headers", headers);
+ io.ably.lib.types.MessageExtras extras = new io.ably.lib.types.MessageExtras(extrasJson);
+
+ /* update using the convenience method with extras */
+ String updateString = "Test data (presence_update_with_extras)";
+ CompletionWaiter updateComplete = new CompletionWaiter();
+ client1Channel.presence.update(updateString, extras, updateComplete);
+ presenceWaiter.waitFor(testClientId1, Action.update);
+ PresenceMessage receivedMessage = presenceWaiter.contains(testClientId1, Action.update);
+ assertNotNull("Verify presence message received", receivedMessage);
+ assertEquals("Verify data matches", updateString, receivedMessage.data);
+
+ /* verify extras field is present and correct */
+ assertNotNull("Verify extras is not null", receivedMessage.extras);
+ JsonObject receivedExtrasJson = receivedMessage.extras.asJsonObject();
+ assertTrue("Verify headers exists in extras", receivedExtrasJson.has("headers"));
+ JsonObject receivedHeaders = receivedExtrasJson.getAsJsonObject("headers");
+ assertEquals("Verify key value matches", "value", receivedHeaders.get("key").getAsString());
+
+ /* verify update callback called on completion */
+ updateComplete.waitFor();
+ assertTrue("Verify update callback called on completion", updateComplete.success);
+ } catch(AblyException e) {
+ e.printStackTrace();
+ fail("Unexpected exception running test: " + e.getMessage());
+ } finally {
+ if(clientAbly1 != null)
+ clientAbly1.close();
+ if(testChannel != null)
+ testChannel.dispose();
+ }
+ }
+
+ /**
+ * Leave presence using the convenience leave(data, extras, listener) method
+ * and verify extras come back on the subscriber side.
+ */
+ @Test
+ public void presence_leave_with_extras() {
+ AblyRealtime clientAbly1 = null;
+ TestChannel testChannel = new TestChannel();
+ try {
+ /* subscribe for presence events in the anonymous connection */
+ PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel);
+ /* set up a connection with specific clientId */
+ ClientOptions client1Opts = new ClientOptions() {{
+ tokenDetails = token1;
+ clientId = testClientId1;
+ }};
+ fillInOptions(client1Opts);
+ clientAbly1 = new AblyRealtime(client1Opts);
+
+ /* wait until connected */
+ (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected);
+ assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected);
+
+ /* get channel and attach */
+ Channel client1Channel = clientAbly1.channels.get(testChannel.channelName);
+ client1Channel.attach();
+ (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached);
+ assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached);
+
+ /* enter first (no extras) */
+ CompletionWaiter enterComplete = new CompletionWaiter();
+ client1Channel.presence.enter("initial data", enterComplete);
+ enterComplete.waitFor();
+ presenceWaiter.waitFor(testClientId1, Action.enter);
+
+ /* create extras with headers */
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("reason", "goodbye");
+ extrasJson.add("headers", headers);
+ io.ably.lib.types.MessageExtras extras = new io.ably.lib.types.MessageExtras(extrasJson);
+
+ /* leave using the convenience method with extras */
+ String leaveString = "Test data (presence_leave_with_extras)";
+ CompletionWaiter leaveComplete = new CompletionWaiter();
+ client1Channel.presence.leave(leaveString, extras, leaveComplete);
+ presenceWaiter.waitFor(testClientId1, Action.leave);
+ PresenceMessage receivedMessage = presenceWaiter.contains(testClientId1, Action.leave);
+ assertNotNull("Verify presence message received", receivedMessage);
+ assertEquals("Verify data matches", leaveString, receivedMessage.data);
+
+ /* verify extras field is present and correct */
+ assertNotNull("Verify extras is not null", receivedMessage.extras);
+ JsonObject receivedExtrasJson = receivedMessage.extras.asJsonObject();
+ assertTrue("Verify headers exists in extras", receivedExtrasJson.has("headers"));
+ JsonObject receivedHeaders = receivedExtrasJson.getAsJsonObject("headers");
+ assertEquals("Verify reason value matches", "goodbye", receivedHeaders.get("reason").getAsString());
+
+ /* verify leave callback called on completion */
+ leaveComplete.waitFor();
+ assertTrue("Verify leave callback called on completion", leaveComplete.success);
+ } catch(AblyException e) {
+ e.printStackTrace();
+ fail("Unexpected exception running test: " + e.getMessage());
+ } finally {
+ if(clientAbly1 != null)
+ clientAbly1.close();
+ if(testChannel != null)
+ testChannel.dispose();
+ }
+ }
+
+ /**
+ * Enter presence for a specific clientId using enterClient(clientId, data, extras, listener)
+ * and verify extras come back on the subscriber side.
+ */
+ @Test
+ public void presence_enterClient_with_extras() {
+ AblyRealtime clientAbly1 = null;
+ TestChannel testChannel = new TestChannel();
+ try {
+ /* subscribe for presence events in the anonymous connection */
+ PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel);
+ /* set up a connection with wildcard clientId capability */
+ ClientOptions client1Opts = new ClientOptions() {{
+ tokenDetails = wildcardToken;
+ }};
+ fillInOptions(client1Opts);
+ clientAbly1 = new AblyRealtime(client1Opts);
+
+ /* wait until connected */
+ (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected);
+ assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected);
+
+ /* get channel and attach */
+ Channel client1Channel = clientAbly1.channels.get(testChannel.channelName);
+ client1Channel.attach();
+ (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached);
+ assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached);
+
+ /* create extras with headers */
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("role", "admin");
+ extrasJson.add("headers", headers);
+ io.ably.lib.types.MessageExtras extras = new io.ably.lib.types.MessageExtras(extrasJson);
+
+ /* enter using enterClient with extras */
+ String enterString = "Test data (presence_enterClient_with_extras)";
+ CompletionWaiter enterComplete = new CompletionWaiter();
+ client1Channel.presence.enterClient(testClientId1, enterString, extras, enterComplete);
+ presenceWaiter.waitFor(testClientId1, Action.enter);
+ PresenceMessage receivedMessage = presenceWaiter.contains(testClientId1, Action.enter);
+ assertNotNull("Verify presence message received", receivedMessage);
+ assertEquals("Verify data matches", enterString, receivedMessage.data);
+
+ /* verify extras field is present and correct */
+ assertNotNull("Verify extras is not null", receivedMessage.extras);
+ JsonObject receivedExtrasJson = receivedMessage.extras.asJsonObject();
+ assertTrue("Verify headers exists in extras", receivedExtrasJson.has("headers"));
+ JsonObject receivedHeaders = receivedExtrasJson.getAsJsonObject("headers");
+ assertEquals("Verify role value matches", "admin", receivedHeaders.get("role").getAsString());
+
+ /* verify enter callback called on completion */
+ enterComplete.waitFor();
+ assertTrue("Verify enter callback called on completion", enterComplete.success);
+ } catch(AblyException e) {
+ e.printStackTrace();
+ fail("Unexpected exception running test: " + e.getMessage());
+ } finally {
+ if(clientAbly1 != null)
+ clientAbly1.close();
+ if(testChannel != null)
+ testChannel.dispose();
+ }
+ }
+
+ /**
+ * Update presence for a specific clientId using updateClient(clientId, data, extras, listener)
+ * and verify extras come back on the subscriber side.
+ */
+ @Test
+ public void presence_updateClient_with_extras() {
+ AblyRealtime clientAbly1 = null;
+ TestChannel testChannel = new TestChannel();
+ try {
+ /* subscribe for presence events in the anonymous connection */
+ PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel);
+ /* set up a connection with wildcard clientId capability */
+ ClientOptions client1Opts = new ClientOptions() {{
+ tokenDetails = wildcardToken;
+ }};
+ fillInOptions(client1Opts);
+ clientAbly1 = new AblyRealtime(client1Opts);
+
+ /* wait until connected */
+ (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected);
+ assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected);
+
+ /* get channel and attach */
+ Channel client1Channel = clientAbly1.channels.get(testChannel.channelName);
+ client1Channel.attach();
+ (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached);
+ assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached);
+
+ /* enter first so we can update */
+ CompletionWaiter enterComplete = new CompletionWaiter();
+ client1Channel.presence.enterClient(testClientId1, "initial data", enterComplete);
+ presenceWaiter.waitFor(testClientId1, Action.enter);
+ enterComplete.waitFor();
+ assertTrue("Verify enter callback called on completion", enterComplete.success);
+
+ /* create extras with headers */
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("role", "editor");
+ extrasJson.add("headers", headers);
+ io.ably.lib.types.MessageExtras extras = new io.ably.lib.types.MessageExtras(extrasJson);
+
+ /* update using updateClient with extras */
+ String updateString = "Test data (presence_updateClient_with_extras)";
+ CompletionWaiter updateComplete = new CompletionWaiter();
+ client1Channel.presence.updateClient(testClientId1, updateString, extras, updateComplete);
+ presenceWaiter.waitFor(testClientId1, Action.update);
+ PresenceMessage receivedMessage = presenceWaiter.contains(testClientId1, Action.update);
+ assertNotNull("Verify presence message received", receivedMessage);
+ assertEquals("Verify data matches", updateString, receivedMessage.data);
+
+ /* verify extras field is present and correct */
+ assertNotNull("Verify extras is not null", receivedMessage.extras);
+ JsonObject receivedExtrasJson = receivedMessage.extras.asJsonObject();
+ assertTrue("Verify headers exists in extras", receivedExtrasJson.has("headers"));
+ JsonObject receivedHeaders = receivedExtrasJson.getAsJsonObject("headers");
+ assertEquals("Verify role value matches", "editor", receivedHeaders.get("role").getAsString());
+
+ /* verify update callback called on completion */
+ updateComplete.waitFor();
+ assertTrue("Verify update callback called on completion", updateComplete.success);
+ } catch(AblyException e) {
+ e.printStackTrace();
+ fail("Unexpected exception running test: " + e.getMessage());
+ } finally {
+ if(clientAbly1 != null)
+ clientAbly1.close();
+ if(testChannel != null)
+ testChannel.dispose();
+ }
+ }
+
+ /**
+ * Leave presence for a specific clientId using leaveClient(clientId, data, extras, listener)
+ * and verify extras come back on the subscriber side.
+ */
+ @Test
+ public void presence_leaveClient_with_extras() {
+ AblyRealtime clientAbly1 = null;
+ TestChannel testChannel = new TestChannel();
+ try {
+ /* subscribe for presence events in the anonymous connection */
+ PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel);
+ /* set up a connection with wildcard clientId capability */
+ ClientOptions client1Opts = new ClientOptions() {{
+ tokenDetails = wildcardToken;
+ }};
+ fillInOptions(client1Opts);
+ clientAbly1 = new AblyRealtime(client1Opts);
+
+ /* wait until connected */
+ (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected);
+ assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected);
+
+ /* get channel and attach */
+ Channel client1Channel = clientAbly1.channels.get(testChannel.channelName);
+ client1Channel.attach();
+ (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached);
+ assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached);
+
+ /* enter first so we can leave */
+ CompletionWaiter enterComplete = new CompletionWaiter();
+ client1Channel.presence.enterClient(testClientId1, "initial data", enterComplete);
+ presenceWaiter.waitFor(testClientId1, Action.enter);
+ enterComplete.waitFor();
+ assertTrue("Verify enter callback called on completion", enterComplete.success);
+
+ /* create extras with headers */
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("role", "departing");
+ extrasJson.add("headers", headers);
+ io.ably.lib.types.MessageExtras extras = new io.ably.lib.types.MessageExtras(extrasJson);
+
+ /* leave using leaveClient with extras */
+ String leaveString = "Test data (presence_leaveClient_with_extras)";
+ CompletionWaiter leaveComplete = new CompletionWaiter();
+ client1Channel.presence.leaveClient(testClientId1, leaveString, extras, leaveComplete);
+ presenceWaiter.waitFor(testClientId1, Action.leave);
+ PresenceMessage receivedMessage = presenceWaiter.contains(testClientId1, Action.leave);
+ assertNotNull("Verify presence message received", receivedMessage);
+ assertEquals("Verify data matches", leaveString, receivedMessage.data);
+
+ /* verify extras field is present and correct */
+ assertNotNull("Verify extras is not null", receivedMessage.extras);
+ JsonObject receivedExtrasJson = receivedMessage.extras.asJsonObject();
+ assertTrue("Verify headers exists in extras", receivedExtrasJson.has("headers"));
+ JsonObject receivedHeaders = receivedExtrasJson.getAsJsonObject("headers");
+ assertEquals("Verify role value matches", "departing", receivedHeaders.get("role").getAsString());
+
+ /* verify leave callback called on completion */
+ leaveComplete.waitFor();
+ assertTrue("Verify leave callback called on completion", leaveComplete.success);
+ } catch(AblyException e) {
+ e.printStackTrace();
+ fail("Unexpected exception running test: " + e.getMessage());
+ } finally {
+ if(clientAbly1 != null)
+ clientAbly1.close();
+ if(testChannel != null)
+ testChannel.dispose();
+ }
+ }
+
+ /**
+ * Enter presence with extras and verify that channel.presence.get()
+ * returns the extras on the PresenceMessage from the local presence map.
+ */
+ @Test
+ public void presence_extras_in_presence_get() {
+ AblyRealtime clientAbly1 = null;
+ TestChannel testChannel = new TestChannel();
+ try {
+ /* subscribe for presence events in the anonymous connection */
+ PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel);
+ /* set up a connection with wildcard clientId capability */
+ ClientOptions client1Opts = new ClientOptions() {{
+ tokenDetails = wildcardToken;
+ }};
+ fillInOptions(client1Opts);
+ clientAbly1 = new AblyRealtime(client1Opts);
+
+ /* wait until connected */
+ (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected);
+ assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected);
+
+ /* get channel and attach */
+ Channel client1Channel = clientAbly1.channels.get(testChannel.channelName);
+ client1Channel.attach();
+ (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached);
+ assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached);
+
+ /* create extras with headers */
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("status", "active");
+ extrasJson.add("headers", headers);
+ io.ably.lib.types.MessageExtras extras = new io.ably.lib.types.MessageExtras(extrasJson);
+
+ /* enter using enterClient with extras */
+ String enterString = "Test data (presence_extras_in_presence_get)";
+ CompletionWaiter enterComplete = new CompletionWaiter();
+ client1Channel.presence.enterClient(testClientId1, enterString, extras, enterComplete);
+ presenceWaiter.waitFor(testClientId1, Action.enter);
+ enterComplete.waitFor();
+ assertTrue("Verify enter callback called on completion", enterComplete.success);
+
+ /* call presence.get() and verify extras on the returned message */
+ PresenceMessage[] members = testChannel.realtimeChannel.presence.get(false);
+ assertNotNull("Verify presence members returned", members);
+ PresenceMessage member = contains(members, testClientId1);
+ assertNotNull("Verify member found in presence set", member);
+ assertEquals("Verify data matches", enterString, member.data);
+
+ /* verify extras field is present and correct */
+ assertNotNull("Verify extras is not null on get() result", member.extras);
+ JsonObject memberExtrasJson = member.extras.asJsonObject();
+ assertTrue("Verify headers exists in extras", memberExtrasJson.has("headers"));
+ JsonObject memberHeaders = memberExtrasJson.getAsJsonObject("headers");
+ assertEquals("Verify status value matches", "active", memberHeaders.get("status").getAsString());
+ } catch(AblyException e) {
+ e.printStackTrace();
+ fail("Unexpected exception running test: " + e.getMessage());
+ } finally {
+ if(clientAbly1 != null)
+ clientAbly1.close();
+ if(testChannel != null)
+ testChannel.dispose();
+ }
+ }
+
+ /**
+ * Enter presence with extras and verify that the extras survive a round-trip
+ * through the REST presence history API.
+ */
+ @Test
+ public void presence_extras_in_rest_history() {
+ AblyRealtime clientAbly1 = null;
+ TestChannel testChannel = new TestChannel();
+ try {
+ /* subscribe for presence events in the anonymous connection */
+ PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel);
+ /* set up a connection with wildcard clientId capability */
+ ClientOptions client1Opts = new ClientOptions() {{
+ tokenDetails = wildcardToken;
+ }};
+ fillInOptions(client1Opts);
+ clientAbly1 = new AblyRealtime(client1Opts);
+
+ /* wait until connected */
+ (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected);
+ assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected);
+
+ /* get channel and attach */
+ Channel client1Channel = clientAbly1.channels.get(testChannel.channelName);
+ client1Channel.attach();
+ (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached);
+ assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached);
+
+ /* create extras with headers */
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("source", "test");
+ extrasJson.add("headers", headers);
+ io.ably.lib.types.MessageExtras extras = new io.ably.lib.types.MessageExtras(extrasJson);
+
+ /* enter using enterClient with extras */
+ String enterString = "Test data (presence_extras_in_rest_history)";
+ CompletionWaiter enterComplete = new CompletionWaiter();
+ client1Channel.presence.enterClient(testClientId1, enterString, extras, enterComplete);
+ presenceWaiter.waitFor(testClientId1, Action.enter);
+ enterComplete.waitFor();
+ assertTrue("Verify enter callback called on completion", enterComplete.success);
+
+ /* query REST presence history and verify extras */
+ PaginatedResult historyPage = testChannel.restChannel.presence.history(
+ new Param[]{ new Param("direction", "forwards") }
+ );
+ assertNotNull("Verify history returned", historyPage);
+ PresenceMessage[] historyMessages = historyPage.items();
+ assertTrue("Verify at least one history message", historyMessages.length > 0);
+
+ /* find the enter message in history */
+ PresenceMessage historyMsg = null;
+ for (PresenceMessage msg : historyMessages) {
+ if (testClientId1.equals(msg.clientId) && msg.action == Action.enter) {
+ historyMsg = msg;
+ break;
+ }
+ }
+ assertNotNull("Verify enter message found in history", historyMsg);
+ assertEquals("Verify data matches", enterString, historyMsg.data);
+
+ /* verify extras field is present and correct */
+ assertNotNull("Verify extras is not null in history", historyMsg.extras);
+ JsonObject historyExtrasJson = historyMsg.extras.asJsonObject();
+ assertTrue("Verify headers exists in extras", historyExtrasJson.has("headers"));
+ JsonObject historyHeaders = historyExtrasJson.getAsJsonObject("headers");
+ assertEquals("Verify source value matches", "test", historyHeaders.get("source").getAsString());
+ } catch(AblyException e) {
+ e.printStackTrace();
+ fail("Unexpected exception running test: " + e.getMessage());
+ } finally {
+ if(clientAbly1 != null)
+ clientAbly1.close();
+ if(testChannel != null)
+ testChannel.dispose();
+ }
+ }
+
+ /**
+ * Verify that the existing two-arg overloads (e.g., enter(data, listener)) still work
+ * correctly and produce a PresenceMessage with null extras, ensuring no regression.
+ */
+ @Test
+ public void presence_null_extras_backward_compatibility() {
+ AblyRealtime clientAbly1 = null;
+ TestChannel testChannel = new TestChannel();
+ try {
+ /* subscribe for presence events in the anonymous connection */
+ PresenceWaiter presenceWaiter = new PresenceWaiter(testChannel.realtimeChannel);
+ /* set up a connection with specific clientId */
+ ClientOptions client1Opts = new ClientOptions() {{
+ tokenDetails = token1;
+ clientId = testClientId1;
+ }};
+ fillInOptions(client1Opts);
+ clientAbly1 = new AblyRealtime(client1Opts);
+
+ /* wait until connected */
+ (new ConnectionWaiter(clientAbly1.connection)).waitFor(ConnectionState.connected);
+ assertEquals("Verify connected state reached", clientAbly1.connection.state, ConnectionState.connected);
+
+ /* get channel and attach */
+ Channel client1Channel = clientAbly1.channels.get(testChannel.channelName);
+ client1Channel.attach();
+ (new ChannelWaiter(client1Channel)).waitFor(ChannelState.attached);
+ assertEquals("Verify attached state reached", client1Channel.state, ChannelState.attached);
+
+ /* enter using the original two-arg overload: enter(data, listener) */
+ String enterString = "Test data (presence_null_extras_backward_compatibility)";
+ CompletionWaiter enterComplete = new CompletionWaiter();
+ client1Channel.presence.enter(enterString, enterComplete);
+ presenceWaiter.waitFor(testClientId1, Action.enter);
+ PresenceMessage enterMsg = presenceWaiter.contains(testClientId1, Action.enter);
+ assertNotNull("Verify enter presence message received", enterMsg);
+ assertEquals("Verify enter data matches", enterString, enterMsg.data);
+ assertNull("Verify extras is null for enter without extras", enterMsg.extras);
+ enterComplete.waitFor();
+ assertTrue("Verify enter callback called on completion", enterComplete.success);
+
+ /* update using the original two-arg overload: update(data, listener) */
+ String updateString = "Updated data (presence_null_extras_backward_compatibility)";
+ CompletionWaiter updateComplete = new CompletionWaiter();
+ client1Channel.presence.update(updateString, updateComplete);
+ presenceWaiter.waitFor(testClientId1, Action.update);
+ PresenceMessage updateMsg = presenceWaiter.contains(testClientId1, Action.update);
+ assertNotNull("Verify update presence message received", updateMsg);
+ assertEquals("Verify update data matches", updateString, updateMsg.data);
+ assertNull("Verify extras is null for update without extras", updateMsg.extras);
+ updateComplete.waitFor();
+ assertTrue("Verify update callback called on completion", updateComplete.success);
+
+ /* leave using the original two-arg overload: leave(data, listener) */
+ String leaveString = "Leave data (presence_null_extras_backward_compatibility)";
+ CompletionWaiter leaveComplete = new CompletionWaiter();
+ client1Channel.presence.leave(leaveString, leaveComplete);
+ presenceWaiter.waitFor(testClientId1, Action.leave);
+ PresenceMessage leaveMsg = presenceWaiter.contains(testClientId1, Action.leave);
+ assertNotNull("Verify leave presence message received", leaveMsg);
+ assertEquals("Verify leave data matches", leaveString, leaveMsg.data);
+ assertNull("Verify extras is null for leave without extras", leaveMsg.extras);
+ leaveComplete.waitFor();
+ assertTrue("Verify leave callback called on completion", leaveComplete.success);
+ } catch(AblyException e) {
+ e.printStackTrace();
+ fail("Unexpected exception running test: " + e.getMessage());
+ } finally {
+ if(clientAbly1 != null)
+ clientAbly1.close();
+ if(testChannel != null)
+ testChannel.dispose();
+ }
+ }
+
static class MessagesData {
public PresenceMessage[] messages;
}
diff --git a/lib/src/test/java/io/ably/lib/types/PresenceMessageTest.java b/lib/src/test/java/io/ably/lib/types/PresenceMessageTest.java
new file mode 100644
index 000000000..2e8d30825
--- /dev/null
+++ b/lib/src/test/java/io/ably/lib/types/PresenceMessageTest.java
@@ -0,0 +1,213 @@
+package io.ably.lib.types;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.ably.lib.util.Serialisation;
+import org.junit.Test;
+import org.msgpack.core.MessagePacker;
+import org.msgpack.core.MessageUnpacker;
+
+import java.io.ByteArrayOutputStream;
+
+public class PresenceMessageTest {
+
+ private final PresenceMessage.Serializer serializer = new PresenceMessage.Serializer();
+
+ /**
+ * Verify the 4-arg PresenceMessage(Action, String, Object, MessageExtras) constructor
+ * correctly sets all fields, and the 3-arg constructor sets extras = null.
+ */
+ @Test
+ public void presenceMessage_constructor_with_extras() {
+ // Given
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("key", "value");
+ extrasJson.add("headers", headers);
+ MessageExtras extras = new MessageExtras(extrasJson);
+
+ // When - 4-arg constructor
+ PresenceMessage msg4 = new PresenceMessage(PresenceMessage.Action.enter, "client1", "data1", extras);
+
+ // Then
+ assertEquals(PresenceMessage.Action.enter, msg4.action);
+ assertEquals("client1", msg4.clientId);
+ assertEquals("data1", msg4.data);
+ assertNotNull("Extras should be set", msg4.extras);
+ assertEquals("value", msg4.extras.asJsonObject().getAsJsonObject("headers").get("key").getAsString());
+
+ // When - 3-arg constructor
+ PresenceMessage msg3 = new PresenceMessage(PresenceMessage.Action.update, "client2", "data2");
+
+ // Then
+ assertEquals(PresenceMessage.Action.update, msg3.action);
+ assertEquals("client2", msg3.clientId);
+ assertEquals("data2", msg3.data);
+ assertNull("Extras should be null for 3-arg constructor", msg3.extras);
+
+ // When - 2-arg constructor
+ PresenceMessage msg2 = new PresenceMessage(PresenceMessage.Action.leave, "client3");
+
+ // Then
+ assertEquals(PresenceMessage.Action.leave, msg2.action);
+ assertEquals("client3", msg2.clientId);
+ assertNull("Data should be null for 2-arg constructor", msg2.data);
+ assertNull("Extras should be null for 2-arg constructor", msg2.extras);
+ }
+
+ /**
+ * Verify that clone() on a PresenceMessage with extras copies the extras field.
+ */
+ @Test
+ public void presenceMessage_clone_copies_extras() {
+ // Given
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("role", "admin");
+ extrasJson.add("headers", headers);
+ MessageExtras extras = new MessageExtras(extrasJson);
+
+ PresenceMessage original = new PresenceMessage(PresenceMessage.Action.enter, "client1", "data1", extras);
+ original.id = "test-id";
+ original.connectionId = "test-connection";
+ original.timestamp = 12345L;
+
+ // When
+ PresenceMessage cloned = (PresenceMessage) original.clone();
+
+ // Then
+ assertEquals("Action should be cloned", original.action, cloned.action);
+ assertEquals("ClientId should be cloned", original.clientId, cloned.clientId);
+ assertEquals("Data should be cloned", original.data, cloned.data);
+ assertEquals("Id should be cloned", original.id, cloned.id);
+ assertEquals("ConnectionId should be cloned", original.connectionId, cloned.connectionId);
+ assertEquals("Timestamp should be cloned", original.timestamp, cloned.timestamp);
+ assertNotNull("Extras should not be null on clone", cloned.extras);
+ assertEquals("Extras should match original",
+ original.extras.asJsonObject(), cloned.extras.asJsonObject());
+ }
+
+ /**
+ * Serialize a PresenceMessage with extras to JSON and deserialize it back;
+ * assert the extras are equal. Also verify that an invalid (non-object) extras
+ * value in JSON produces the expected error.
+ */
+ @Test
+ public void presenceMessage_extras_json_roundtrip() {
+ // Given
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("foo", "bar");
+ headers.addProperty("count", 42);
+ extrasJson.add("headers", headers);
+ MessageExtras extras = new MessageExtras(extrasJson);
+
+ PresenceMessage original = new PresenceMessage(PresenceMessage.Action.enter, "client1", "test-data", extras);
+
+ // When - serialize
+ JsonElement serialized = serializer.serialize(original, null, null);
+ JsonObject json = serialized.getAsJsonObject();
+
+ // Then - verify JSON structure
+ assertNotNull("Extras should be in JSON", json.get("extras"));
+ JsonObject jsonExtras = json.getAsJsonObject("extras");
+ assertEquals("bar", jsonExtras.getAsJsonObject("headers").get("foo").getAsString());
+ assertEquals(42, jsonExtras.getAsJsonObject("headers").get("count").getAsInt());
+
+ // When - deserialize
+ PresenceMessage deserialized = serializer.deserialize(json, null, null);
+
+ // Then - verify round-trip
+ assertEquals("Action should survive round-trip", PresenceMessage.Action.enter, deserialized.action);
+ assertEquals("ClientId should survive round-trip", "client1", deserialized.clientId);
+ assertEquals("Data should survive round-trip", "test-data", deserialized.data);
+ assertNotNull("Extras should survive round-trip", deserialized.extras);
+ JsonObject deserializedHeaders = deserialized.extras.asJsonObject().getAsJsonObject("headers");
+ assertEquals("foo header should survive round-trip", "bar", deserializedHeaders.get("foo").getAsString());
+ assertEquals("count header should survive round-trip", 42, deserializedHeaders.get("count").getAsInt());
+
+ // Verify null extras in JSON round-trips correctly
+ PresenceMessage noExtras = new PresenceMessage(PresenceMessage.Action.leave, "client2", "data2");
+ JsonElement serializedNoExtras = serializer.serialize(noExtras, null, null);
+ JsonObject jsonNoExtras = serializedNoExtras.getAsJsonObject();
+ assertNull("Extras should not be in JSON when null", jsonNoExtras.get("extras"));
+ PresenceMessage deserializedNoExtras = serializer.deserialize(jsonNoExtras, null, null);
+ assertNull("Extras should remain null after round-trip", deserializedNoExtras.extras);
+
+ // Verify invalid (non-object) extras produces error
+ JsonObject invalidJson = new JsonObject();
+ invalidJson.addProperty("action", PresenceMessage.Action.enter.getValue());
+ invalidJson.addProperty("clientId", "client1");
+ invalidJson.addProperty("extras", "not-an-object");
+ try {
+ serializer.deserialize(invalidJson, null, null);
+ fail("Expected exception for non-object extras");
+ } catch (Exception e) {
+ // Expected - invalid extras should cause an error
+ }
+ }
+
+ /**
+ * Serialize a PresenceMessage with extras via writeMsgpack / fromMsgpack
+ * and assert the extras survive the round-trip.
+ */
+ @Test
+ public void presenceMessage_extras_msgpack_roundtrip() throws Exception {
+ // Given - message with extras
+ JsonObject extrasJson = new JsonObject();
+ JsonObject headers = new JsonObject();
+ headers.addProperty("key", "value");
+ headers.addProperty("num", 99);
+ extrasJson.add("headers", headers);
+ MessageExtras extras = new MessageExtras(extrasJson);
+
+ PresenceMessage original = new PresenceMessage(PresenceMessage.Action.update, "client1", "test-data", extras);
+
+ // When - encode to MessagePack
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ MessagePacker packer = Serialisation.msgpackPackerConfig.newPacker(out);
+ original.writeMsgpack(packer);
+ packer.close();
+
+ // Decode from MessagePack
+ MessageUnpacker unpacker = Serialisation.msgpackUnpackerConfig.newUnpacker(out.toByteArray());
+ PresenceMessage unpacked = PresenceMessage.fromMsgpack(unpacker);
+ unpacker.close();
+
+ // Then
+ assertEquals("Action should survive msgpack round-trip", PresenceMessage.Action.update, unpacked.action);
+ assertEquals("ClientId should survive msgpack round-trip", "client1", unpacked.clientId);
+ assertEquals("Data should survive msgpack round-trip", "test-data", unpacked.data);
+ assertNotNull("Extras should survive msgpack round-trip", unpacked.extras);
+ JsonObject unpackedHeaders = unpacked.extras.asJsonObject().getAsJsonObject("headers");
+ assertEquals("key header should survive round-trip", "value", unpackedHeaders.get("key").getAsString());
+ assertEquals("num header should survive round-trip", 99, unpackedHeaders.get("num").getAsInt());
+
+ // Verify null extras case - field count should be different
+ PresenceMessage noExtras = new PresenceMessage(PresenceMessage.Action.leave, "client2", "data2");
+
+ ByteArrayOutputStream outNoExtras = new ByteArrayOutputStream();
+ MessagePacker packerNoExtras = Serialisation.msgpackPackerConfig.newPacker(outNoExtras);
+ noExtras.writeMsgpack(packerNoExtras);
+ packerNoExtras.close();
+
+ MessageUnpacker unpackerNoExtras = Serialisation.msgpackUnpackerConfig.newUnpacker(outNoExtras.toByteArray());
+ PresenceMessage unpackedNoExtras = PresenceMessage.fromMsgpack(unpackerNoExtras);
+ unpackerNoExtras.close();
+
+ assertEquals("Action should survive round-trip", PresenceMessage.Action.leave, unpackedNoExtras.action);
+ assertEquals("ClientId should survive round-trip", "client2", unpackedNoExtras.clientId);
+ assertEquals("Data should survive round-trip", "data2", unpackedNoExtras.data);
+ assertNull("Extras should be null when not set", unpackedNoExtras.extras);
+
+ // Verify the packed sizes differ (extras adds fields)
+ assertTrue("Message with extras should be larger than without",
+ out.toByteArray().length > outNoExtras.toByteArray().length);
+ }
+}
diff --git a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimePresence.kt b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimePresence.kt
index cbd00805e..447df43ae 100644
--- a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimePresence.kt
+++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimePresence.kt
@@ -4,6 +4,7 @@ import com.ably.Subscription
import io.ably.lib.realtime.ChannelState
import io.ably.lib.realtime.CompletionListener
import io.ably.lib.realtime.Presence.PresenceListener
+import io.ably.lib.types.MessageExtras
import io.ably.lib.types.PresenceMessage
import java.util.*
@@ -81,7 +82,22 @@ public interface RealtimePresence : Presence {
* @param listener A callback to notify of the success or failure of the operation.
* This listener is invoked on a background thread.
*/
- public fun enter(data: Any? = null, listener: CompletionListener? = null)
+ public fun enter(data: Any? = null, listener: CompletionListener? = null): Unit =
+ enter(data, null, listener)
+
+ /**
+ * Enters the presence set for the channel, optionally passing a data payload and extras.
+ * A clientId is required to be present on a channel.
+ * An optional callback may be provided to notify of the success or failure of the operation.
+ *
+ * Spec: RTP8
+ *
+ * @param data The payload associated with the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener A callback to notify of the success or failure of the operation.
+ * This listener is invoked on a background thread.
+ */
+ public fun enter(data: Any?, extras: MessageExtras?, listener: CompletionListener? = null)
/**
* Updates the data payload for a presence member.
@@ -94,7 +110,22 @@ public interface RealtimePresence : Presence {
* @param listener A callback to notify of the success or failure of the operation.
* This listener is invoked on a background thread.
*/
- public fun update(data: Any? = null, listener: CompletionListener? = null)
+ public fun update(data: Any? = null, listener: CompletionListener? = null): Unit =
+ update(data, null, listener)
+
+ /**
+ * Updates the data payload for a presence member, optionally passing extras.
+ * If called before entering the presence set, this is treated as an [PresenceMessage.Action.enter] event.
+ * An optional callback may be provided to notify of the success or failure of the operation.
+ *
+ * Spec: RTP9
+ *
+ * @param data The payload associated with the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener A callback to notify of the success or failure of the operation.
+ * This listener is invoked on a background thread.
+ */
+ public fun update(data: Any?, extras: MessageExtras?, listener: CompletionListener? = null)
/**
* Leaves the presence set for the channel.
@@ -106,7 +137,21 @@ public interface RealtimePresence : Presence {
* @param listener a listener to notify of the success or failure of the operation.
* This listener is invoked on a background thread.
*/
- public fun leave(data: Any? = null, listener: CompletionListener? = null)
+ public fun leave(data: Any? = null, listener: CompletionListener? = null): Unit =
+ leave(data, null, listener)
+
+ /**
+ * Leaves the presence set for the channel, optionally passing extras.
+ * A client must have previously entered the presence set before they can leave it.
+ *
+ * Spec: RTP10
+ *
+ * @param data The payload associated with the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener a listener to notify of the success or failure of the operation.
+ * This listener is invoked on a background thread.
+ */
+ public fun leave(data: Any?, extras: MessageExtras?, listener: CompletionListener? = null)
/**
* Enters the presence set of the channel for a given clientId.
@@ -120,7 +165,23 @@ public interface RealtimePresence : Presence {
* @param listener A callback to notify of the success or failure of the operation.
* This listener is invoked on a background thread.
*/
- public fun enterClient(clientId: String, data: Any? = null, listener: CompletionListener? = null)
+ public fun enterClient(clientId: String, data: Any? = null, listener: CompletionListener? = null): Unit =
+ enterClient(clientId, data, null, listener)
+
+ /**
+ * Enters the presence set of the channel for a given clientId, optionally passing extras.
+ * Enables a single client to update presence on behalf of any number of clients using a single connection.
+ * The library must have been instantiated with an API key or a token bound to a wildcard clientId.
+ *
+ * Spec: RTP4, RTP14, RTP15
+ *
+ * @param clientId The ID of the client to enter into the presence set.
+ * @param data The payload associated with the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener A callback to notify of the success or failure of the operation.
+ * This listener is invoked on a background thread.
+ */
+ public fun enterClient(clientId: String, data: Any?, extras: MessageExtras?, listener: CompletionListener? = null)
/**
* Updates the data payload for a presence member using a given clientId.
@@ -135,7 +196,24 @@ public interface RealtimePresence : Presence {
* @param listener A callback to notify of the success or failure of the operation.
* This listener is invoked on a background thread.
*/
- public fun updateClient(clientId: String, data: Any? = null, listener: CompletionListener? = null)
+ public fun updateClient(clientId: String, data: Any? = null, listener: CompletionListener? = null): Unit =
+ updateClient(clientId, data, null, listener)
+
+ /**
+ * Updates the data payload for a presence member using a given clientId, optionally passing extras.
+ * Enables a single client to update presence on behalf of any number of clients using a single connection.
+ * The library must have been instantiated with an API key or a token bound to a wildcard clientId.
+ * An optional callback may be provided to notify of the success or failure of the operation.
+ *
+ * Spec: RTP15
+ *
+ * @param clientId The ID of the client to update in the presence set.
+ * @param data The payload to update for the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener A callback to notify of the success or failure of the operation.
+ * This listener is invoked on a background thread.
+ */
+ public fun updateClient(clientId: String, data: Any?, extras: MessageExtras?, listener: CompletionListener? = null)
/**
* Leaves the presence set of the channel for a given clientId.
@@ -149,5 +227,21 @@ public interface RealtimePresence : Presence {
* @param listener A callback to notify of the success or failure of the operation.
* This listener is invoked on a background thread.
*/
- public fun leaveClient(clientId: String?, data: Any? = null, listener: CompletionListener? = null)
+ public fun leaveClient(clientId: String?, data: Any? = null, listener: CompletionListener? = null): Unit =
+ leaveClient(clientId, data, null, listener)
+
+ /**
+ * Leaves the presence set of the channel for a given clientId, optionally passing extras.
+ * Enables a single client to update presence on behalf of any number of clients using a single connection.
+ * The library must have been instantiated with an API key or a token bound to a wildcard clientId.
+ *
+ * Spec: RTP15
+ *
+ * @param clientId The ID of the client to leave the presence set for.
+ * @param data The payload associated with the presence member.
+ * @param extras The extras associated with the presence member.
+ * @param listener A callback to notify of the success or failure of the operation.
+ * This listener is invoked on a background thread.
+ */
+ public fun leaveClient(clientId: String?, data: Any?, extras: MessageExtras?, listener: CompletionListener? = null)
}
diff --git a/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimePresenceAdapter.kt b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimePresenceAdapter.kt
index 441c3dace..bf0e8ab5f 100644
--- a/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimePresenceAdapter.kt
+++ b/pubsub-adapter/src/main/kotlin/io/ably/lib/realtime/RealtimePresenceAdapter.kt
@@ -44,20 +44,23 @@ internal class RealtimePresenceAdapter(private val javaPresence: Presence) : Rea
}
}
- override fun enter(data: Any?, listener: CompletionListener?) = javaPresence.enter(data, listener)
+ override fun enter(data: Any?, extras: MessageExtras?, listener: CompletionListener?) =
+ javaPresence.enter(data, extras, listener)
- override fun update(data: Any?, listener: CompletionListener?) = javaPresence.update(data, listener)
+ override fun update(data: Any?, extras: MessageExtras?, listener: CompletionListener?) =
+ javaPresence.update(data, extras, listener)
- override fun leave(data: Any?, listener: CompletionListener?) = javaPresence.leave(data, listener)
+ override fun leave(data: Any?, extras: MessageExtras?, listener: CompletionListener?) =
+ javaPresence.leave(data, extras, listener)
- override fun enterClient(clientId: String, data: Any?, listener: CompletionListener?) =
- javaPresence.enterClient(clientId, data, listener)
+ override fun enterClient(clientId: String, data: Any?, extras: MessageExtras?, listener: CompletionListener?) =
+ javaPresence.enterClient(clientId, data, extras, listener)
- override fun updateClient(clientId: String, data: Any?, listener: CompletionListener?) =
- javaPresence.updateClient(clientId, data, listener)
+ override fun updateClient(clientId: String, data: Any?, extras: MessageExtras?, listener: CompletionListener?) =
+ javaPresence.updateClient(clientId, data, extras, listener)
- override fun leaveClient(clientId: String?, data: Any?, listener: CompletionListener?) =
- javaPresence.leaveClient(clientId, data, listener)
+ override fun leaveClient(clientId: String?, data: Any?, extras: MessageExtras?, listener: CompletionListener?) =
+ javaPresence.leaveClient(clientId, data, extras, listener)
override fun history(start: Long?, end: Long?, limit: Int, orderBy: OrderBy): PaginatedResult =
javaPresence.history(buildHistoryParams(start, end, limit, orderBy).toTypedArray())