diff --git a/lib/src/main/java/io/ably/lib/realtime/Presence.java b/lib/src/main/java/io/ably/lib/realtime/Presence.java index 9a7e89e7e..f795a6a8a 100644 --- a/lib/src/main/java/io/ably/lib/realtime/Presence.java +++ b/lib/src/main/java/io/ably/lib/realtime/Presence.java @@ -10,6 +10,7 @@ import io.ably.lib.types.Callback; import io.ably.lib.types.ErrorInfo; import io.ably.lib.types.MessageDecodeException; +import io.ably.lib.types.MessageExtras; import io.ably.lib.types.PaginatedResult; import io.ably.lib.types.Param; import io.ably.lib.types.PresenceMessage; @@ -483,8 +484,27 @@ private void unsubscribeImpl(PresenceMessage.Action action, PresenceListener lis * @throws AblyException */ public void enter(Object data, CompletionListener listener) throws AblyException { + enter(data, null, listener); + } + + /** + * Enters the presence set for the channel, optionally passing a data payload and extras. + * A clientId is required to be present on a channel. + * An optional callback may be provided to notify of the success or failure of the operation. + * + *

+ * Spec: RTP8 + * + * @param data The payload associated with the presence member. + * @param extras The extras associated with the presence member. + * @param listener A callback to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. + * @throws AblyException + */ + public void enter(Object data, MessageExtras extras, CompletionListener listener) throws AblyException { Log.v(TAG, "enter(); channel = " + channel.name); - updatePresence(new PresenceMessage(PresenceMessage.Action.enter, null, data), listener); + updatePresence(new PresenceMessage(PresenceMessage.Action.enter, null, data, extras), listener); } /** @@ -502,8 +522,27 @@ public void enter(Object data, CompletionListener listener) throws AblyException * @throws AblyException */ public void update(Object data, CompletionListener listener) throws AblyException { + update(data, null, listener); + } + + /** + * Updates the data payload for a presence member, optionally passing extras. + * If called before entering the presence set, this is treated as an {@link PresenceMessage.Action#enter} event. + * An optional callback may be provided to notify of the success or failure of the operation. + * + *

+ * Spec: RTP9 + * + * @param data The payload associated with the presence member. + * @param extras The extras associated with the presence member. + * @param listener A callback to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. + * @throws AblyException + */ + public void update(Object data, MessageExtras extras, CompletionListener listener) throws AblyException { Log.v(TAG, "update(); channel = " + channel.name); - updatePresence(new PresenceMessage(PresenceMessage.Action.update, null, data), listener); + updatePresence(new PresenceMessage(PresenceMessage.Action.update, null, data, extras), listener); } /** @@ -520,8 +559,26 @@ public void update(Object data, CompletionListener listener) throws AblyExceptio * @throws AblyException */ public void leave(Object data, CompletionListener listener) throws AblyException { + leave(data, null, listener); + } + + /** + * Leaves the presence set for the channel, optionally passing extras. + * A client must have previously entered the presence set before they can leave it. + * + *

+ * Spec: RTP10 + * + * @param data The payload associated with the presence member. + * @param extras The extras associated with the presence member. + * @param listener a listener to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. + * @throws AblyException + */ + public void leave(Object data, MessageExtras extras, CompletionListener listener) throws AblyException { Log.v(TAG, "leave(); channel = " + channel.name); - updatePresence(new PresenceMessage(PresenceMessage.Action.leave, null, data), listener); + updatePresence(new PresenceMessage(PresenceMessage.Action.leave, null, data, extras), listener); } /** @@ -584,6 +641,25 @@ public void enterClient(String clientId, Object data) throws AblyException { * This listener is invoked on a background thread. */ public void enterClient(String clientId, Object data, CompletionListener listener) throws AblyException { + enterClient(clientId, data, null, listener); + } + + /** + * Enters the presence set of the channel for a given clientId, optionally passing extras. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * + *

+ * Spec: RTP4, RTP14, RTP15 + * + * @param clientId The ID of the client to enter into the presence set. + * @param data The payload associated with the presence member. + * @param extras The extras associated with the presence member. + * @param listener A callback to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. + */ + public void enterClient(String clientId, Object data, MessageExtras extras, CompletionListener listener) throws AblyException { if(clientId == null) { String errorMessage = String.format(Locale.ROOT, "Channel %s: unable to enter presence channel (null clientId specified)", channel.name); Log.v(TAG, errorMessage); @@ -593,7 +669,7 @@ public void enterClient(String clientId, Object data, CompletionListener listene } } Log.v(TAG, "enterClient(); channel = " + channel.name + "; clientId = " + clientId); - updatePresence(new PresenceMessage(PresenceMessage.Action.enter, clientId, data), listener); + updatePresence(new PresenceMessage(PresenceMessage.Action.enter, clientId, data, extras), listener); } private void enterClientWithId(String id, String clientId, Object data, CompletionListener listener) throws AblyException { @@ -658,6 +734,26 @@ public void updateClient(String clientId, Object data) throws AblyException { * This listener is invoked on a background thread. */ public void updateClient(String clientId, Object data, CompletionListener listener) throws AblyException { + updateClient(clientId, data, null, listener); + } + + /** + * Updates the data payload for a presence member using a given clientId, optionally passing extras. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * An optional callback may be provided to notify of the success or failure of the operation. + * + *

+ * Spec: RTP15 + * + * @param clientId The ID of the client to update in the presence set. + * @param data The payload to update for the presence member. + * @param extras The extras associated with the presence member. + * @param listener A callback to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. + */ + public void updateClient(String clientId, Object data, MessageExtras extras, CompletionListener listener) throws AblyException { if(clientId == null) { String errorMessage = String.format(Locale.ROOT, "Channel %s: unable to update presence channel (null clientId specified)", channel.name); Log.v(TAG, errorMessage); @@ -667,7 +763,7 @@ public void updateClient(String clientId, Object data, CompletionListener listen } } Log.v(TAG, "updateClient(); channel = " + channel.name + "; clientId = " + clientId); - updatePresence(new PresenceMessage(PresenceMessage.Action.update, clientId, data), listener); + updatePresence(new PresenceMessage(PresenceMessage.Action.update, clientId, data, extras), listener); } /** @@ -714,6 +810,25 @@ public void leaveClient(String clientId, Object data) throws AblyException { * This listener is invoked on a background thread. */ public void leaveClient(String clientId, Object data, CompletionListener listener) throws AblyException { + leaveClient(clientId, data, null, listener); + } + + /** + * Leaves the presence set of the channel for a given clientId, optionally passing extras. + * Enables a single client to update presence on behalf of any number of clients using a single connection. + * The library must have been instantiated with an API key or a token bound to a wildcard clientId. + * + *

+ * Spec: RTP15 + * + * @param clientId The ID of the client to leave the presence set for. + * @param data The payload associated with the presence member. + * @param extras The extras associated with the presence member. + * @param listener A callback to notify of the success or failure of the operation. + *

+ * This listener is invoked on a background thread. + */ + public void leaveClient(String clientId, Object data, MessageExtras extras, CompletionListener listener) throws AblyException { if(clientId == null) { String errorMessage = String.format(Locale.ROOT, "Channel %s: unable to leave presence channel (null clientId specified)", channel.name); Log.v(TAG, errorMessage); @@ -723,7 +838,7 @@ public void leaveClient(String clientId, Object data, CompletionListener listene } } Log.v(TAG, "leaveClient(); channel = " + channel.name + "; clientId = " + clientId); - updatePresence(new PresenceMessage(PresenceMessage.Action.leave, clientId, data), listener); + updatePresence(new PresenceMessage(PresenceMessage.Action.leave, clientId, data, extras), listener); } /** diff --git a/lib/src/main/java/io/ably/lib/types/PresenceMessage.java b/lib/src/main/java/io/ably/lib/types/PresenceMessage.java index d3073a879..2ba220ba0 100644 --- a/lib/src/main/java/io/ably/lib/types/PresenceMessage.java +++ b/lib/src/main/java/io/ably/lib/types/PresenceMessage.java @@ -75,6 +75,16 @@ public enum Action { */ public Action action; + /** + * A MessageExtras object of arbitrary key-value pairs that may contain metadata, and/or ancillary payloads. + * Valid payloads include {@link DeltaExtras}, {@link JsonObject}. + *

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