From 8e5acc4675bd565095d32659932f3b399c55f677 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 12 Feb 2026 17:29:33 +0000 Subject: [PATCH 1/4] 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 1ef78297ac160d3631f66d44d331bb44cd916bc8 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 12 Feb 2026 19:13:28 +0000 Subject: [PATCH 2/4] 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). Also fixes a bug in enterInternalMembers() (RTP17g) where extras were dropped on automatic re-enter after reconnect. Co-Authored-By: Claude Opus 4.6 --- .../java/io/ably/lib/realtime/Presence.java | 133 ++++++++- .../io/ably/lib/types/PresenceMessage.java | 12 + .../test/realtime/RealtimePresenceTest.java | 275 ++++++++++++++++++ 3 files changed, 411 insertions(+), 9 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..2c785b9e5 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,10 +669,10 @@ 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 { + private void enterClientWithId(String id, 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); @@ -605,7 +681,7 @@ private void enterClientWithId(String id, String clientId, Object data, Completi return; } } - PresenceMessage presenceMsg = new PresenceMessage(PresenceMessage.Action.enter, clientId, data); + PresenceMessage presenceMsg = new PresenceMessage(PresenceMessage.Action.enter, clientId, data, extras); presenceMsg.id = id; Log.v(TAG, "enterClient(); channel = " + channel.name + "; clientId = " + clientId); updatePresence(presenceMsg, listener); @@ -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); } /** @@ -934,7 +1049,7 @@ void onAttached(boolean hasPresence) { void enterInternalMembers() { for (final PresenceMessage item: internalPresence.members.values()) { try { - enterClientWithId(item.id, item.clientId, item.data, new CompletionListener() { + enterClientWithId(item.id, item.clientId, item.data, item.extras, new CompletionListener() { @Override public void onSuccess() { } 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 f90331721b697d7a5f9da5cb56f74591ed43abc5 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 12 Feb 2026 23:19:54 +0000 Subject: [PATCH 3/4] 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 787ea3ba0ac09518505d85135236cad5088f3c12 Mon Sep 17 00:00:00 2001 From: Andy Ford Date: Thu, 12 Feb 2026 23:24:52 +0000 Subject: [PATCH 4/4] 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");