From 8e5acc4675bd565095d32659932f3b399c55f677 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 12 Feb 2026 17:29:33 +0000 Subject: [PATCH 1/5] feat: add extras field to PresenceMessage (TP3i) Add extras field to PresenceMessage type with support for both msgpack and JSON serialization/deserialization. Includes end-to-end test verifying extras round-trip through presence enter. Co-Authored-By: Claude Opus 4.6 --- .../io/ably/lib/types/PresenceMessage.java | 57 ++++++++++++++- .../test/realtime/RealtimePresenceTest.java | 73 +++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) 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..4811a7484 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 */ @@ -123,16 +133,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 +161,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 +278,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 (null != extrasElement) { + if (!(extrasElement instanceof JsonObject)) { + throw MessageDecodeException.fromDescription("PresenceMessage extras is of type \"" + extrasElement.getClass() + "\" when expected a JSON object."); + } + extras = MessageExtras.read((JsonObject) extrasElement); + } + + 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 +304,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..08ac0e157 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,79 @@ 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(); + } + } + static class MessagesData { public PresenceMessage[] messages; } From b1e8f76aab2fd3a14e19176f329414f6c19dfaee Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 12 Feb 2026 19:13:28 +0000 Subject: [PATCH 2/5] feat: add extras parameter to Presence enter/update/leave methods Add MessageExtras overloads to enter(), update(), leave() and their *Client variants so callers can pass extras without dropping down to updatePresence(PresenceMessage, CompletionListener). Existing methods delegate to the new overloads with null extras (fully backward-compatible). Co-Authored-By: Claude Opus 4.6 --- .../java/io/ably/lib/realtime/Presence.java | 127 +++++++- .../io/ably/lib/types/PresenceMessage.java | 12 + .../test/realtime/RealtimePresenceTest.java | 275 ++++++++++++++++++ 3 files changed, 408 insertions(+), 6 deletions(-) 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 4811a7484..dfda67b7a 100644 --- a/lib/src/main/java/io/ably/lib/types/PresenceMessage.java +++ b/lib/src/main/java/io/ably/lib/types/PresenceMessage.java @@ -106,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; } /** 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 08ac0e157..3d0c8dc81 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 @@ -3698,6 +3698,281 @@ public void presence_enter_with_extras() { } } + /** + * 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(); + } + } + static class MessagesData { public PresenceMessage[] messages; } From 0cc921862ddbd8cbd40d6f0bed42eb6e20fddd20 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 12 Feb 2026 23:19:54 +0000 Subject: [PATCH 3/5] feat: add extras parameter to Kotlin presence wrappers Update RealtimePresence interface and RealtimePresenceAdapter to accept MessageExtras on enter, update, leave and their *Client variants, matching the new Java overloads. The extras parameter defaults to null so existing callers are unaffected. Co-Authored-By: Claude Opus 4.6 --- .../com/ably/pubsub/RealtimePresence.kt | 31 ++++++++++++------- .../lib/realtime/RealtimePresenceAdapter.kt | 21 +++++++------ 2 files changed, 31 insertions(+), 21 deletions(-) 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..8f5873d16 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.* @@ -71,45 +72,48 @@ public interface RealtimePresence : Presence { public fun subscribe(actions: EnumSet, listener: PresenceListener): Subscription /** - * Enters the presence set for the channel, optionally passing a data payload. + * 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? = null, listener: CompletionListener? = null) + public fun enter(data: Any? = null, extras: MessageExtras? = null, listener: CompletionListener? = null) /** - * Updates the data payload for a presence member. + * 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? = null, listener: CompletionListener? = null) + public fun update(data: Any? = null, extras: MessageExtras? = null, listener: CompletionListener? = null) /** - * Leaves the presence set for the channel. + * 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? = null, listener: CompletionListener? = null) + public fun leave(data: Any? = null, extras: MessageExtras? = null, listener: CompletionListener? = null) /** - * Enters the presence set of the channel for a given clientId. + * 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. * @@ -117,13 +121,14 @@ public interface RealtimePresence : Presence { * * @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? = null, listener: CompletionListener? = null) + public fun enterClient(clientId: String, data: Any? = null, extras: MessageExtras? = null, listener: CompletionListener? = null) /** - * Updates the data payload for a presence member using a given clientId. + * 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. @@ -132,13 +137,14 @@ public interface RealtimePresence : Presence { * * @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? = null, listener: CompletionListener? = null) + public fun updateClient(clientId: String, data: Any? = null, extras: MessageExtras? = null, listener: CompletionListener? = null) /** - * Leaves the presence set of the channel for a given clientId. + * 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. * @@ -146,8 +152,9 @@ public interface RealtimePresence : Presence { * * @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? = null, listener: CompletionListener? = null) + public fun leaveClient(clientId: String?, data: Any? = null, extras: MessageExtras? = null, 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()) From a646200f8ef24b82ae9b3a9f2f169fe168726160 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 12 Feb 2026 23:24:52 +0000 Subject: [PATCH 4/5] fix: handle JSON null for extras in PresenceMessage deserialization Treat `"extras": null` as absent rather than throwing a MessageDecodeException. This avoids a hard failure when incoming JSON explicitly sets extras to null. Co-Authored-By: Claude Opus 4.6 --- lib/src/main/java/io/ably/lib/types/PresenceMessage.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 dfda67b7a..2ba220ba0 100644 --- a/lib/src/main/java/io/ably/lib/types/PresenceMessage.java +++ b/lib/src/main/java/io/ably/lib/types/PresenceMessage.java @@ -295,11 +295,11 @@ protected void read(final JsonObject map) throws MessageDecodeException { super.read(map); final JsonElement extrasElement = map.get(EXTRAS); - if (null != extrasElement) { - if (!(extrasElement instanceof JsonObject)) { + 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((JsonObject) extrasElement); + extras = MessageExtras.read(extrasElement.getAsJsonObject()); } Integer actionValue = readInt(map, "action"); From b29c8a1dcf911266884c3f726344816238028a8e Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Sun, 8 Mar 2026 11:38:44 +0000 Subject: [PATCH 5/5] fix: restore backward-compatible Kotlin presence API and add extras tests Restore the original 2-arg presence method signatures (enter, update, leave, enterClient, updateClient, leaveClient) as default interface methods that delegate to the new extras-aware overloads, fixing the breaking change in the Kotlin API. Add comprehensive test coverage for PresenceMessage extras: - Integration tests: updateClient/leaveClient with extras, presence.get() with extras, REST history with extras, null extras backward compat - Unit tests: constructor, clone, JSON round-trip, msgpack round-trip Co-Authored-By: Claude Opus 4.6 --- .../test/realtime/RealtimePresenceTest.java | 365 ++++++++++++++++++ .../ably/lib/types/PresenceMessageTest.java | 213 ++++++++++ .../com/ably/pubsub/RealtimePresence.kt | 99 ++++- 3 files changed, 671 insertions(+), 6 deletions(-) create mode 100644 lib/src/test/java/io/ably/lib/types/PresenceMessageTest.java 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 3d0c8dc81..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 @@ -3973,6 +3973,371 @@ public void presence_enterClient_with_extras() { } } + /** + * 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 8f5873d16..447df43ae 100644 --- a/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimePresence.kt +++ b/pubsub-adapter/src/main/kotlin/com/ably/pubsub/RealtimePresence.kt @@ -71,6 +71,20 @@ public interface RealtimePresence : Presence { */ public fun subscribe(actions: EnumSet, listener: PresenceListener): Subscription + /** + * Enters the presence set for the channel, optionally passing a data payload. + * 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 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): 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. @@ -83,7 +97,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 enter(data: Any? = null, extras: MessageExtras? = null, listener: CompletionListener? = null) + public fun enter(data: Any?, extras: MessageExtras?, listener: CompletionListener? = null) + + /** + * Updates the data payload for a presence member. + * 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 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): Unit = + update(data, null, listener) /** * Updates the data payload for a presence member, optionally passing extras. @@ -97,7 +125,20 @@ 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, extras: MessageExtras? = null, listener: CompletionListener? = null) + public fun update(data: Any?, extras: MessageExtras?, listener: CompletionListener? = null) + + /** + * Leaves the presence set for the channel. + * 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 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): Unit = + leave(data, null, listener) /** * Leaves the presence set for the channel, optionally passing extras. @@ -110,7 +151,22 @@ 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, extras: MessageExtras? = null, listener: CompletionListener? = null) + public fun leave(data: Any?, extras: MessageExtras?, listener: CompletionListener? = null) + + /** + * Enters the presence set of the channel for a given clientId. + * 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 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): Unit = + enterClient(clientId, data, null, listener) /** * Enters the presence set of the channel for a given clientId, optionally passing extras. @@ -125,7 +181,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, extras: MessageExtras? = null, listener: CompletionListener? = null) + public fun enterClient(clientId: String, data: Any?, extras: MessageExtras?, listener: CompletionListener? = null) + + /** + * Updates the data payload for a presence member using a given clientId. + * 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 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): Unit = + updateClient(clientId, data, null, listener) /** * Updates the data payload for a presence member using a given clientId, optionally passing extras. @@ -141,7 +213,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 updateClient(clientId: String, data: Any? = null, extras: MessageExtras? = null, listener: CompletionListener? = null) + public fun updateClient(clientId: String, data: Any?, extras: MessageExtras?, listener: CompletionListener? = null) + + /** + * Leaves the presence set of the channel for a given clientId. + * 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 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): Unit = + leaveClient(clientId, data, null, listener) /** * Leaves the presence set of the channel for a given clientId, optionally passing extras. @@ -156,5 +243,5 @@ 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, extras: MessageExtras? = null, listener: CompletionListener? = null) + public fun leaveClient(clientId: String?, data: Any?, extras: MessageExtras?, listener: CompletionListener? = null) }