From 9e505297438af96a3de0604277056d8c455a90e4 Mon Sep 17 00:00:00 2001 From: Rafael Marinho Date: Fri, 4 Apr 2025 16:01:35 +0200 Subject: [PATCH] live location --- DOCS.md | 61 ++++++++ .../getstream/chat/java/models/Channel.java | 58 ++++++++ .../chat/java/models/ChannelType.java | 35 +++++ .../getstream/chat/java/models/Message.java | 92 ++++++++++++ .../io/getstream/chat/java/models/User.java | 28 ++++ .../chat/java/services/ChannelService.java | 6 + .../chat/java/services/UserService.java | 5 + .../getstream/chat/java/LiveLocationTest.java | 131 ++++++++++++++++++ 8 files changed, 416 insertions(+) create mode 100644 src/test/java/io/getstream/chat/java/LiveLocationTest.java diff --git a/DOCS.md b/DOCS.md index 8a3166a56..62e6efe15 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1860,4 +1860,65 @@ Import.createImport(createUrlResponse.getPath(), Import.ImportMode.Upsert); ```java // signature comes from the HTTP header x-signature boolean valid = App.verifyWebhook(body, signature) +``` + +## Live Location Features + +Stream Chat supports sharing and updating a user's live location within a channel. There are a few ways to work with live locations: + +### Sending a Message with Live Location + +The simplest way to share a location is to send a message containing live location data: + +```java +Date endTime = new Date(System.currentTimeMillis() + 3600 * 1000); // 1 hour from now + +// Send a message with live location +Message message = Message.sendWithLiveLocation( + "messaging", // channel type + "my-channel-id", // channel id + "Sharing my location", // message text + "user-id", // user id + 40.7128, // latitude (New York City) + -74.0060, // longitude + endTime, // when the live location sharing ends + "ios-device-123" // device ID that's sharing the location +) +.request() +.getMessage(); +``` + +### Updating a Live Location + +Once a live location has been created, you can update it with new coordinates: + +```java +// Update existing live location +LiveLocationUpdateRequestData requestData = LiveLocationUpdateRequestData.builder() + .locationId("existing-location-id") + .userId("user-id") + .latitude(34.0522) // new latitude (Los Angeles) + .longitude(-118.2437) // new longitude + .build(); + +Channel.updateLiveLocation("messaging", "my-channel-id") + .liveLocation(requestData) + .request(); +``` + +### Getting a User's Active Live Locations + +To retrieve all active live locations for a user: + +```java +UserGetActiveLiveLocationsResponse response = User.getActiveLiveLocations("user-id") + .request(); + +List liveLocations = response.getLiveLocations(); +for (LiveLocation location : liveLocations) { + System.out.println("Location ID: " + location.getId()); + System.out.println("Latitude: " + location.getLatitude()); + System.out.println("Longitude: " + location.getLongitude()); + System.out.println("End time: " + location.getEndAt()); +} ``` \ No newline at end of file diff --git a/src/main/java/io/getstream/chat/java/models/Channel.java b/src/main/java/io/getstream/chat/java/models/Channel.java index 2e4ffd344..67f48625b 100644 --- a/src/main/java/io/getstream/chat/java/models/Channel.java +++ b/src/main/java/io/getstream/chat/java/models/Channel.java @@ -1725,4 +1725,62 @@ public static ChannelMemberPartialUpdateRequest unarchive( @NotNull String type, @NotNull String id, @NotNull String userId) { return new ChannelMemberPartialUpdateRequest(type, id, userId).setValue("archived", false); } + + @Builder( + builderClassName = "LiveLocationUpdateRequest", + builderMethodName = "", + buildMethodName = "internalBuild") + public static class LiveLocationUpdateRequestData { + @Nullable + @JsonProperty("location_id") + private String locationId; + + @Nullable + @JsonProperty("user_id") + private String userId; + + @Nullable + @JsonProperty("latitude") + private Double latitude; + + @Nullable + @JsonProperty("longitude") + private Double longitude; + + @Nullable + @JsonProperty("end_at") + private Date endAt; + + @Nullable + @JsonProperty("created_by_device_id") + private String createdByDeviceId; + + public static class LiveLocationUpdateRequest extends StreamRequest { + @NotNull private String channelType; + @NotNull private String channelId; + @NotNull private LiveLocationUpdateRequestData liveLocationData; + + private LiveLocationUpdateRequest(@NotNull String channelType, @NotNull String channelId) { + this.channelType = channelType; + this.channelId = channelId; + } + + public LiveLocationUpdateRequest liveLocation(@NotNull LiveLocationUpdateRequestData data) { + this.liveLocationData = data; + return this; + } + + @Override + protected Call generateCall(Client client) { + return client + .create(ChannelService.class) + .updateLiveLocation(this.channelType, this.channelId, this.liveLocationData); + } + } + } + + public static LiveLocationUpdateRequestData.LiveLocationUpdateRequest updateLiveLocation( + @NotNull String channelType, @NotNull String channelId) { + return new LiveLocationUpdateRequestData.LiveLocationUpdateRequest(channelType, channelId); + } } diff --git a/src/main/java/io/getstream/chat/java/models/ChannelType.java b/src/main/java/io/getstream/chat/java/models/ChannelType.java index 18ddf9f0f..6eeafb8e5 100644 --- a/src/main/java/io/getstream/chat/java/models/ChannelType.java +++ b/src/main/java/io/getstream/chat/java/models/ChannelType.java @@ -673,4 +673,39 @@ public static ChannelTypeDeleteRequest delete(String name) { public static ChannelTypeListRequest list() throws StreamException { return new ChannelTypeListRequest(); } + + /** + * Represents a live location shared in a channel + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class LiveLocation { + private String id; + + @JsonProperty("user_id") + private String userId; + + @JsonProperty("channel_cid") + private String channelCid; + + @JsonProperty("message_id") + private String messageId; + + private Double latitude; + private Double longitude; + + @JsonProperty("end_at") + private Date endAt; + + @JsonProperty("created_by_device_id") + private String createdByDeviceId; + + @JsonProperty("created_at") + private Date createdAt; + + @JsonProperty("updated_at") + private Date updatedAt; + } } diff --git a/src/main/java/io/getstream/chat/java/models/Message.java b/src/main/java/io/getstream/chat/java/models/Message.java index f037c063a..ee7defd42 100644 --- a/src/main/java/io/getstream/chat/java/models/Message.java +++ b/src/main/java/io/getstream/chat/java/models/Message.java @@ -28,6 +28,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import retrofit2.Call; +import java.util.UUID; @Data @NoArgsConstructor @@ -168,6 +169,10 @@ public class Message { @JsonProperty("pinned_at") private Date pinnedAt; + @Nullable + @JsonProperty("live_location") + private LiveLocationRequestObject liveLocation; + @NotNull @JsonIgnore private Map additionalFields = new HashMap<>(); @JsonAnyGetter @@ -491,6 +496,10 @@ public static class MessageRequestObject { @JsonProperty("pinned_at") private Date pinnedAt; + @Nullable + @JsonProperty("live_location") + private LiveLocationRequestObject liveLocation; + @Singular @Nullable @JsonIgnore private Map additionalFields; @JsonAnyGetter @@ -654,6 +663,43 @@ public static FieldRequestObject buildFrom(@Nullable Field field) { } } + @Builder + @Setter + public static class LiveLocationRequestObject { + @Nullable + @JsonProperty("id") + private String id; + + @Nullable + @JsonProperty("user_id") + private String userId; + + @Nullable + @JsonProperty("channel_cid") + private String channelCid; + + @Nullable + @JsonProperty("latitude") + private Double latitude; + + @Nullable + @JsonProperty("longitude") + private Double longitude; + + @Nullable + @JsonProperty("end_at") + private Date endAt; + + @Nullable + @JsonProperty("created_by_device_id") + private String createdByDeviceId; + + @Nullable + public static LiveLocationRequestObject buildFrom(@Nullable ChannelType.LiveLocation liveLocation) { + return RequestObjectBuilder.build(LiveLocationRequestObject.class, liveLocation); + } + } + @Builder @Setter public static class ImageSizeRequestObject { @@ -1692,4 +1738,50 @@ public static MessagePartialUpdateRequest unpinMessage( public static MessageUnblockRequest unblock(@NotNull String messageId) { return new MessageUnblockRequest().targetMessageId(messageId); } + + /** + * Helper method to create a message with a live location + * + * @param channelType the channel type + * @param channelId the channel id + * @param text the message text + * @param userId the user id + * @param latitude the location latitude + * @param longitude the location longitude + * @param endAt the end time for the live location + * @param deviceId the device id that created the location + * @return the message send request + */ + @NotNull + public static MessageSendRequest sendWithLiveLocation( + @NotNull String channelType, + @NotNull String channelId, + @Nullable String text, + @NotNull String userId, + @NotNull Double latitude, + @NotNull Double longitude, + @NotNull Date endAt, + @NotNull String deviceId) { + + String locationId = UUID.randomUUID().toString(); + String channelCid = channelType + ":" + channelId; + + LiveLocationRequestObject liveLocation = LiveLocationRequestObject.builder() + .id(locationId) + .userId(userId) + .channelCid(channelCid) + .latitude(latitude) + .longitude(longitude) + .endAt(endAt) + .createdByDeviceId(deviceId) + .build(); + + MessageRequestObject message = MessageRequestObject.builder() + .text(text) + .userId(userId) + .liveLocation(liveLocation) + .build(); + + return send(channelType, channelId).message(message); + } } diff --git a/src/main/java/io/getstream/chat/java/models/User.java b/src/main/java/io/getstream/chat/java/models/User.java index f12be03d7..f26f98ecd 100644 --- a/src/main/java/io/getstream/chat/java/models/User.java +++ b/src/main/java/io/getstream/chat/java/models/User.java @@ -1447,4 +1447,32 @@ public static String createToken( .signWith(signingKey, SignatureAlgorithm.HS256) .compact(); } + + @Data + @NoArgsConstructor + @EqualsAndHashCode(callSuper = true) + public static class UserGetActiveLiveLocationsResponse extends StreamResponseObject { + @Nullable + @JsonProperty("live_locations") + private List liveLocations; + } + + public static class UserGetActiveLiveLocationsRequest extends StreamRequest { + @NotNull private String userId; + + private UserGetActiveLiveLocationsRequest(@NotNull String userId) { + this.userId = userId; + } + + @Override + protected Call generateCall(Client client) { + return client + .create(UserService.class) + .getUserActiveLiveLocations(this.userId, this.userId); + } + } + + public static UserGetActiveLiveLocationsRequest getActiveLiveLocations(@NotNull String userId) { + return new UserGetActiveLiveLocationsRequest(userId); + } } diff --git a/src/main/java/io/getstream/chat/java/services/ChannelService.java b/src/main/java/io/getstream/chat/java/services/ChannelService.java index fc59def59..4bd66d02c 100644 --- a/src/main/java/io/getstream/chat/java/services/ChannelService.java +++ b/src/main/java/io/getstream/chat/java/services/ChannelService.java @@ -103,4 +103,10 @@ Call updateMemberPartial( @NotNull @Path("id") String channelId, @NotNull @Path("user_id") String userId, @NotNull @Body ChannelMemberPartialUpdateRequestData updateMemberPartialRequestData); + + @PUT("channels/{type}/{id}/live_location") + Call updateLiveLocation( + @NotNull @Path("type") String channelType, + @NotNull @Path("id") String channelId, + @NotNull @Body Channel.LiveLocationUpdateRequestData liveLocationUpdateRequestData); } diff --git a/src/main/java/io/getstream/chat/java/services/UserService.java b/src/main/java/io/getstream/chat/java/services/UserService.java index d6032e30e..31c650b7c 100644 --- a/src/main/java/io/getstream/chat/java/services/UserService.java +++ b/src/main/java/io/getstream/chat/java/services/UserService.java @@ -66,4 +66,9 @@ Call unban( @Nullable @Query("type") String channelType, @Nullable @Query("id") String channelId, @Nullable @Query("shadow") Boolean shadow); + + @GET("users/{user_id}/live_locations") + Call getUserActiveLiveLocations( + @NotNull @Path("user_id") String userId, + @NotNull @Query("user_id") String queryUserId); } diff --git a/src/test/java/io/getstream/chat/java/LiveLocationTest.java b/src/test/java/io/getstream/chat/java/LiveLocationTest.java new file mode 100644 index 000000000..1d8436d24 --- /dev/null +++ b/src/test/java/io/getstream/chat/java/LiveLocationTest.java @@ -0,0 +1,131 @@ +package io.getstream.chat.java; + +import io.getstream.chat.java.exceptions.StreamException; +import io.getstream.chat.java.models.Channel.LiveLocationUpdateRequestData; +import io.getstream.chat.java.models.ChannelType.LiveLocation; +import io.getstream.chat.java.models.Message; +import io.getstream.chat.java.models.User.UserGetActiveLiveLocationsResponse; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Date; + +public class LiveLocationTest extends BasicTest { + + @DisplayName("Can update live location") + @Test + void whenUpdatingLiveLocation_thenNoException() { + // Given + String locationId = "test-location-" + System.currentTimeMillis(); + Date endTime = new Date(System.currentTimeMillis() + 3600 * 1000); // 1 hour from now + + LiveLocationUpdateRequestData requestData = LiveLocationUpdateRequestData.builder() + .locationId(locationId) + .userId(testUserRequestObject.getId()) + .latitude(40.7128) + .longitude(-74.0060) + .endAt(endTime) + .createdByDeviceId("test-device") + .build(); + + // When/Then + Assertions.assertDoesNotThrow(() -> + Channel.updateLiveLocation(testChannel.getType(), testChannel.getId()) + .liveLocation(requestData) + .request() + ); + } + + @DisplayName("Can get user active live locations") + @Test + void whenGettingUserActiveLiveLocations_thenNoException() { + // Create a live location first to ensure there's at least one to retrieve + String locationId = "test-location-" + System.currentTimeMillis(); + Date endTime = new Date(System.currentTimeMillis() + 3600 * 1000); // 1 hour from now + + LiveLocationUpdateRequestData requestData = LiveLocationUpdateRequestData.builder() + .locationId(locationId) + .userId(testUserRequestObject.getId()) + .latitude(40.7128) + .longitude(-74.0060) + .endAt(endTime) + .createdByDeviceId("test-device") + .build(); + + Assertions.assertDoesNotThrow(() -> + Channel.updateLiveLocation(testChannel.getType(), testChannel.getId()) + .liveLocation(requestData) + .request() + ); + + // Small delay to ensure the location is created + pause(); + + // When/Then + UserGetActiveLiveLocationsResponse response = Assertions.assertDoesNotThrow(() -> + User.getActiveLiveLocations(testUserRequestObject.getId()) + .request() + ); + + // Verify we have at least one live location + Assertions.assertNotNull(response.getLiveLocations()); + } + + @DisplayName("Handles invalid live location data") + @Test + void whenSendingInvalidLiveLocationData_thenThrowsException() { + LiveLocationUpdateRequestData requestData = LiveLocationUpdateRequestData.builder() + // Missing required fields + .userId(testUserRequestObject.getId()) + .build(); + + // This should either throw a validation exception or return an error from the API + Assertions.assertThrows(StreamException.class, () -> + Channel.updateLiveLocation(testChannel.getType(), testChannel.getId()) + .liveLocation(requestData) + .request() + ); + } + + @DisplayName("Can send message with live location") + @Test + void whenSendingMessageWithLiveLocation_thenMessageContainsLiveLocation() { + // Given + Date endTime = new Date(System.currentTimeMillis() + 3600 * 1000); // 1 hour from now + + // When + Message message = Assertions.assertDoesNotThrow(() -> + Message.sendWithLiveLocation( + testChannel.getType(), + testChannel.getId(), + "Sharing my location", + testUserRequestObject.getId(), + 40.7128, + -74.0060, + endTime, + "test-device" + ) + .request() + .getMessage() + ); + + // Small delay to ensure the location is processed + pause(); + + // Then + Assertions.assertNotNull(message); + + // Verify the live location is attached to the message + Assertions.assertNotNull(message.getLiveLocation()); + + // Verify user's active live locations include this location + UserGetActiveLiveLocationsResponse response = Assertions.assertDoesNotThrow(() -> + User.getActiveLiveLocations(testUserRequestObject.getId()) + .request() + ); + + Assertions.assertNotNull(response.getLiveLocations()); + Assertions.assertTrue(response.getLiveLocations().size() > 0); + } +} \ No newline at end of file