Fix remote activities
This commit is contained in:
parent
40173c269f
commit
a560036265
4 changed files with 309 additions and 20 deletions
|
|
@ -75,23 +75,25 @@ public class TimelineService {
|
||||||
|
|
||||||
// 3. Fetch local activities from followed users (fetch more to account for merging)
|
// 3. Fetch local activities from followed users (fetch more to account for merging)
|
||||||
// We fetch double the page size to have enough items after merging
|
// We fetch double the page size to have enough items after merging
|
||||||
// Explicitly sort by startedAt DESC (latest first)
|
// Explicitly sort by startedAt DESC (latest first) for local activities
|
||||||
Pageable expandedPageable = PageRequest.of(0, pageable.getPageSize() * 2,
|
Pageable expandedPageableLocal = PageRequest.of(0, pageable.getPageSize() * 2,
|
||||||
org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "startedAt"));
|
org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "startedAt"));
|
||||||
Page<Activity> localActivities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
Page<Activity> localActivities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
||||||
followedUserIds,
|
followedUserIds,
|
||||||
List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS),
|
List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS),
|
||||||
expandedPageable
|
expandedPageableLocal
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. Fetch remote activities from followed remote actors (if any)
|
// 4. Fetch remote activities from followed remote actors (if any)
|
||||||
List<RemoteActivity> remoteActivities = new ArrayList<>();
|
List<RemoteActivity> remoteActivities = new ArrayList<>();
|
||||||
if (!remoteActorUris.isEmpty()) {
|
if (!remoteActorUris.isEmpty()) {
|
||||||
// Use same pageable with explicit sort for remote activities
|
// Use publishedAt for sorting remote activities (not startedAt)
|
||||||
|
Pageable expandedPageableRemote = PageRequest.of(0, pageable.getPageSize() * 2,
|
||||||
|
org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "publishedAt"));
|
||||||
Page<RemoteActivity> remoteActivitiesPage = remoteActivityRepository.findByRemoteActorUriInAndVisibilityIn(
|
Page<RemoteActivity> remoteActivitiesPage = remoteActivityRepository.findByRemoteActorUriInAndVisibilityIn(
|
||||||
remoteActorUris,
|
remoteActorUris,
|
||||||
List.of(RemoteActivity.Visibility.PUBLIC, RemoteActivity.Visibility.FOLLOWERS),
|
List.of(RemoteActivity.Visibility.PUBLIC, RemoteActivity.Visibility.FOLLOWERS),
|
||||||
expandedPageable
|
expandedPageableRemote
|
||||||
);
|
);
|
||||||
remoteActivities = remoteActivitiesPage.getContent();
|
remoteActivities = remoteActivitiesPage.getContent();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -210,10 +210,10 @@ public class FitParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BigDecimal totalDistance = null;
|
||||||
if (session.getTotalDistance() != null) {
|
if (session.getTotalDistance() != null) {
|
||||||
parsedData.setTotalDistance(
|
totalDistance = BigDecimal.valueOf(session.getTotalDistance()).setScale(2, RoundingMode.HALF_UP);
|
||||||
BigDecimal.valueOf(session.getTotalDistance()).setScale(2, RoundingMode.HALF_UP)
|
parsedData.setTotalDistance(totalDistance);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.getTotalAscent() != null) {
|
if (session.getTotalAscent() != null) {
|
||||||
|
|
@ -231,12 +231,6 @@ public class FitParser {
|
||||||
// Extract metrics
|
// Extract metrics
|
||||||
ActivityMetricsData metrics = new ActivityMetricsData();
|
ActivityMetricsData metrics = new ActivityMetricsData();
|
||||||
|
|
||||||
if (session.getAvgSpeed() != null) {
|
|
||||||
metrics.setAverageSpeed(
|
|
||||||
BigDecimal.valueOf(session.getAvgSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.getMaxSpeed() != null) {
|
if (session.getMaxSpeed() != null) {
|
||||||
metrics.setMaxSpeed(
|
metrics.setMaxSpeed(
|
||||||
BigDecimal.valueOf(session.getMaxSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP)
|
BigDecimal.valueOf(session.getMaxSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP)
|
||||||
|
|
@ -275,17 +269,55 @@ public class FitParser {
|
||||||
metrics.setCalories(session.getTotalCalories());
|
metrics.setCalories(session.getTotalCalories());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Duration movingTime = null;
|
||||||
if (session.getTotalMovingTime() != null) {
|
if (session.getTotalMovingTime() != null) {
|
||||||
metrics.setMovingTime(Duration.ofSeconds(session.getTotalMovingTime().longValue()));
|
movingTime = Duration.ofSeconds(session.getTotalMovingTime().longValue());
|
||||||
|
metrics.setMovingTime(movingTime);
|
||||||
|
} else if (session.getTotalTimerTime() != null) {
|
||||||
|
// Fallback to timer time if moving time is not available
|
||||||
|
movingTime = Duration.ofSeconds(session.getTotalTimerTime().longValue());
|
||||||
|
metrics.setMovingTime(movingTime);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: Calculate moving time from track points if native value is not available
|
// Fallback: Calculate moving time from track points if native value is not available
|
||||||
Duration calculatedMovingTime = calculateMovingTimeFromTrackPoints(parsedData);
|
movingTime = calculateMovingTimeFromTrackPoints(parsedData);
|
||||||
if (calculatedMovingTime != null) {
|
if (movingTime != null) {
|
||||||
metrics.setMovingTime(calculatedMovingTime);
|
metrics.setMovingTime(movingTime);
|
||||||
log.debug("Calculated moving time from track points: {}", calculatedMovingTime);
|
log.debug("Calculated moving time from track points: {}", movingTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate stopped time (elapsed - moving)
|
||||||
|
if (parsedData.getTotalDuration() != null && movingTime != null) {
|
||||||
|
Duration stoppedTime = parsedData.getTotalDuration().minus(movingTime);
|
||||||
|
if (stoppedTime.isNegative()) {
|
||||||
|
stoppedTime = Duration.ZERO;
|
||||||
|
}
|
||||||
|
metrics.setStoppedTime(stoppedTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average speed from distance and moving time (not from FIT SDK's avgSpeed)
|
||||||
|
// This ensures consistency with GPX parser and correct calculation based on moving time only
|
||||||
|
if (totalDistance != null && movingTime != null && movingTime.getSeconds() > 0) {
|
||||||
|
// distance in meters / time in seconds = m/s, then * 3.6 = km/h
|
||||||
|
double avgSpeedKmh = (totalDistance.doubleValue() / movingTime.getSeconds()) * 3.6;
|
||||||
|
metrics.setAverageSpeed(
|
||||||
|
BigDecimal.valueOf(avgSpeedKmh).setScale(2, RoundingMode.HALF_UP)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate average pace (min/km) for running activities
|
||||||
|
// pace = time (seconds) / distance (km)
|
||||||
|
double distanceKm = totalDistance.doubleValue() / 1000.0;
|
||||||
|
if (distanceKm > 0) {
|
||||||
|
long paceSecondsPerKm = (long) (movingTime.getSeconds() / distanceKm);
|
||||||
|
metrics.setAveragePace(Duration.ofSeconds(paceSecondsPerKm));
|
||||||
|
}
|
||||||
|
} else if (session.getAvgSpeed() != null) {
|
||||||
|
// Fallback to FIT SDK's average speed if moving time is not available
|
||||||
|
metrics.setAverageSpeed(
|
||||||
|
BigDecimal.valueOf(session.getAvgSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (session.getTotalStrides() != null) {
|
if (session.getTotalStrides() != null) {
|
||||||
metrics.setTotalSteps(session.getTotalStrides().intValue() * 2); // Strides to steps
|
metrics.setTotalSteps(session.getTotalStrides().intValue() * 2); // Strides to steps
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -414,8 +414,22 @@ public class GpxParser {
|
||||||
parsedData.setElevationLoss(BigDecimal.valueOf(elevationLoss).setScale(2, RoundingMode.HALF_UP));
|
parsedData.setElevationLoss(BigDecimal.valueOf(elevationLoss).setScale(2, RoundingMode.HALF_UP));
|
||||||
|
|
||||||
// Calculate average and max values
|
// Calculate average and max values
|
||||||
|
// Calculate average speed from total distance and moving time (not from all speed values)
|
||||||
|
if (totalDistance > 0 && movingTime.getSeconds() > 0) {
|
||||||
|
// Convert: distance (meters) / time (seconds) = m/s, then * 3.6 = km/h
|
||||||
|
double avgSpeedKmh = (totalDistance / movingTime.getSeconds()) * 3.6;
|
||||||
|
metrics.setAverageSpeed(BigDecimal.valueOf(avgSpeedKmh).setScale(2, RoundingMode.HALF_UP));
|
||||||
|
|
||||||
|
// Calculate average pace (min/km) for running activities
|
||||||
|
// pace = time (seconds) / distance (km)
|
||||||
|
double distanceKm = totalDistance / 1000.0;
|
||||||
|
if (distanceKm > 0) {
|
||||||
|
long paceSecondsPerKm = (long) (movingTime.getSeconds() / distanceKm);
|
||||||
|
metrics.setAveragePace(Duration.ofSeconds(paceSecondsPerKm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!speeds.isEmpty()) {
|
if (!speeds.isEmpty()) {
|
||||||
metrics.setAverageSpeed(calculateAverage(speeds));
|
|
||||||
metrics.setMaxSpeed(speeds.stream().max(BigDecimal::compareTo).orElse(null));
|
metrics.setMaxSpeed(speeds.stream().max(BigDecimal::compareTo).orElse(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
package org.operaton.fitpub.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.operaton.fitpub.model.dto.TimelineActivityDTO;
|
||||||
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
|
import org.operaton.fitpub.model.entity.Follow;
|
||||||
|
import org.operaton.fitpub.model.entity.RemoteActivity;
|
||||||
|
import org.operaton.fitpub.model.entity.User;
|
||||||
|
import org.operaton.fitpub.repository.ActivityRepository;
|
||||||
|
import org.operaton.fitpub.repository.FollowRepository;
|
||||||
|
import org.operaton.fitpub.repository.RemoteActivityRepository;
|
||||||
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for TimelineService.
|
||||||
|
* Tests timeline retrieval with mixed local and remote activities.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class TimelineServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ActivityRepository activityRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private RemoteActivityRepository remoteActivityRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FollowRepository followRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private org.operaton.fitpub.repository.LikeRepository likeRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private org.operaton.fitpub.repository.CommentRepository commentRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private org.operaton.fitpub.repository.RemoteActorRepository remoteActorRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private TimelineService timelineService;
|
||||||
|
|
||||||
|
private UUID userId;
|
||||||
|
private User testUser;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
userId = UUID.randomUUID();
|
||||||
|
testUser = User.builder()
|
||||||
|
.id(userId)
|
||||||
|
.username("testuser")
|
||||||
|
.email("test@example.com")
|
||||||
|
.displayName("Test User")
|
||||||
|
.enabled(true)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should retrieve federated timeline with remote activities without errors")
|
||||||
|
void testGetFederatedTimeline_WithRemoteActivities_NoError() {
|
||||||
|
// Given: User follows remote actors
|
||||||
|
List<Follow> follows = List.of(
|
||||||
|
createRemoteFollow("https://remote.example/users/alice"),
|
||||||
|
createRemoteFollow("https://remote.example/users/bob")
|
||||||
|
);
|
||||||
|
|
||||||
|
when(followRepository.findAcceptedFollowingByUserId(eq(userId)))
|
||||||
|
.thenReturn(follows);
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||||
|
|
||||||
|
// Mock local activities
|
||||||
|
List<Activity> localActivities = List.of(
|
||||||
|
createLocalActivity("Morning Run", LocalDateTime.now().minusHours(2))
|
||||||
|
);
|
||||||
|
Page<Activity> localActivitiesPage = new PageImpl<>(localActivities);
|
||||||
|
when(activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
||||||
|
anyList(), anyList(), any(Pageable.class)))
|
||||||
|
.thenReturn(localActivitiesPage);
|
||||||
|
|
||||||
|
// Mock remote activities - this should use publishedAt for sorting
|
||||||
|
List<RemoteActivity> remoteActivities = List.of(
|
||||||
|
createRemoteActivity("Remote Run 1", Instant.now().minusSeconds(3600)),
|
||||||
|
createRemoteActivity("Remote Run 2", Instant.now().minusSeconds(7200))
|
||||||
|
);
|
||||||
|
Page<RemoteActivity> remoteActivitiesPage = new PageImpl<>(remoteActivities);
|
||||||
|
when(remoteActivityRepository.findByRemoteActorUriInAndVisibilityIn(
|
||||||
|
anyList(), anyList(), any(Pageable.class)))
|
||||||
|
.thenReturn(remoteActivitiesPage);
|
||||||
|
|
||||||
|
Pageable pageable = PageRequest.of(0, 20);
|
||||||
|
|
||||||
|
// When - This should NOT throw an exception about 'startedAt' not found
|
||||||
|
Page<TimelineActivityDTO> result = assertDoesNotThrow(() ->
|
||||||
|
timelineService.getFederatedTimeline(userId, pageable)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(result, "Result should not be null");
|
||||||
|
assertFalse(result.isEmpty(), "Result should contain activities");
|
||||||
|
|
||||||
|
// Verify that both repositories were called
|
||||||
|
verify(activityRepository).findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
||||||
|
anyList(), anyList(), any(Pageable.class));
|
||||||
|
verify(remoteActivityRepository).findByRemoteActorUriInAndVisibilityIn(
|
||||||
|
anyList(), anyList(), any(Pageable.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should handle empty remote activities gracefully")
|
||||||
|
void testGetFederatedTimeline_NoRemoteActivities() {
|
||||||
|
// Given: User has no remote follows
|
||||||
|
when(followRepository.findAcceptedFollowingByUserId(eq(userId)))
|
||||||
|
.thenReturn(new ArrayList<>());
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||||
|
|
||||||
|
// Mock local activities only
|
||||||
|
List<Activity> localActivities = List.of(
|
||||||
|
createLocalActivity("Solo Run", LocalDateTime.now())
|
||||||
|
);
|
||||||
|
Page<Activity> localActivitiesPage = new PageImpl<>(localActivities);
|
||||||
|
when(activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
||||||
|
anyList(), anyList(), any(Pageable.class)))
|
||||||
|
.thenReturn(localActivitiesPage);
|
||||||
|
|
||||||
|
Pageable pageable = PageRequest.of(0, 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Page<TimelineActivityDTO> result = timelineService.getFederatedTimeline(userId, pageable);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(1, result.getContent().size());
|
||||||
|
|
||||||
|
// Verify remote repository was NOT called (no remote follows)
|
||||||
|
verify(remoteActivityRepository, never()).findByRemoteActorUriInAndVisibilityIn(
|
||||||
|
anyList(), anyList(), any(Pageable.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should merge local and remote activities without errors")
|
||||||
|
void testGetFederatedTimeline_MergedActivitiesSorted() {
|
||||||
|
// Given
|
||||||
|
List<Follow> follows = List.of(
|
||||||
|
createRemoteFollow("https://remote.example/users/alice")
|
||||||
|
);
|
||||||
|
when(followRepository.findAcceptedFollowingByUserId(eq(userId)))
|
||||||
|
.thenReturn(follows);
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||||
|
|
||||||
|
// Local activity
|
||||||
|
Activity localActivity = createLocalActivity("Local Run", LocalDateTime.now().minusHours(1));
|
||||||
|
Page<Activity> localActivitiesPage = new PageImpl<>(List.of(localActivity));
|
||||||
|
when(activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
||||||
|
anyList(), anyList(), any(Pageable.class)))
|
||||||
|
.thenReturn(localActivitiesPage);
|
||||||
|
|
||||||
|
// Remote activity
|
||||||
|
RemoteActivity remoteActivity = createRemoteActivity("Remote Run", Instant.now().minusSeconds(7200));
|
||||||
|
Page<RemoteActivity> remoteActivitiesPage = new PageImpl<>(List.of(remoteActivity));
|
||||||
|
when(remoteActivityRepository.findByRemoteActorUriInAndVisibilityIn(
|
||||||
|
anyList(), anyList(), any(Pageable.class)))
|
||||||
|
.thenReturn(remoteActivitiesPage);
|
||||||
|
|
||||||
|
Pageable pageable = PageRequest.of(0, 20);
|
||||||
|
|
||||||
|
// When - Should not throw exception
|
||||||
|
Page<TimelineActivityDTO> result = assertDoesNotThrow(() ->
|
||||||
|
timelineService.getFederatedTimeline(userId, pageable)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Then - Should have at least one activity
|
||||||
|
assertNotNull(result);
|
||||||
|
assertFalse(result.isEmpty(), "Timeline should contain activities");
|
||||||
|
assertTrue(result.getContent().size() >= 1, "Timeline should merge both local and remote activities");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
|
||||||
|
private Follow createRemoteFollow(String remoteActorUri) {
|
||||||
|
Follow follow = new Follow();
|
||||||
|
follow.setId(UUID.randomUUID());
|
||||||
|
follow.setFollowerId(userId);
|
||||||
|
follow.setFollowingActorUri(remoteActorUri);
|
||||||
|
follow.setStatus(Follow.FollowStatus.ACCEPTED);
|
||||||
|
return follow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Activity createLocalActivity(String title, LocalDateTime startedAt) {
|
||||||
|
Activity activity = new Activity();
|
||||||
|
activity.setId(UUID.randomUUID());
|
||||||
|
activity.setUserId(userId);
|
||||||
|
activity.setTitle(title);
|
||||||
|
activity.setDescription("Test activity");
|
||||||
|
activity.setActivityType(Activity.ActivityType.RUN);
|
||||||
|
activity.setStartedAt(startedAt);
|
||||||
|
activity.setVisibility(Activity.Visibility.PUBLIC);
|
||||||
|
activity.setTotalDistance(java.math.BigDecimal.valueOf(5000));
|
||||||
|
activity.setTotalDurationSeconds(1800L);
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RemoteActivity createRemoteActivity(String title, Instant publishedAt) {
|
||||||
|
return RemoteActivity.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.activityUri("https://remote.example/activities/" + UUID.randomUUID())
|
||||||
|
.remoteActorUri("https://remote.example/users/alice")
|
||||||
|
.title(title)
|
||||||
|
.description("Remote test activity")
|
||||||
|
.activityType("RUN")
|
||||||
|
.publishedAt(publishedAt)
|
||||||
|
.visibility(RemoteActivity.Visibility.PUBLIC)
|
||||||
|
.totalDistance(5000L)
|
||||||
|
.totalDurationSeconds(1800L)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue