Add latest location and avatar to owntracks friends (#619)

This commit is contained in:
Daniel Graf
2026-01-03 14:05:17 +01:00
committed by GitHub
parent d267b09329
commit 6f3804b362
4 changed files with 190 additions and 133 deletions

View File

@@ -48,7 +48,7 @@ public class GpxSender {
public static void main(String[] args) {
if (args.length < 1) {
System.err.println("Usage: java -jar gpx-sender.jar <gpx-file> --url <reitti-url> --token <api-token> [--interval <seconds>] [--original-time]");
System.err.println("Usage: java -jar gpx-sender.jar <gpx-file> --url <reitti-url> --token <api-token> [--interval <seconds>] [--original-time] [--verbose]");
System.err.println("Example: java -jar gpx-sender.jar track.gpx --url http://localhost:8080 --token your-api-token --interval 15");
System.err.println(" java -jar gpx-sender.jar track.gpx --url http://localhost:8080 --token your-api-token --original-time");
System.exit(1);
@@ -59,6 +59,7 @@ public class GpxSender {
String apiToken = null;
double intervalSeconds = 15.0;
boolean useOriginalTime = false;
boolean verboseOutput = false;
// Parse named parameters
for (int i = 1; i < args.length; i++) {
@@ -82,6 +83,10 @@ public class GpxSender {
case "-ot":
useOriginalTime = true;
break;
case "--verbose":
case "-v":
verboseOutput = true;
break;
default:
System.err.println("Unknown parameter: " + args[i]);
System.exit(1);
@@ -107,8 +112,11 @@ public class GpxSender {
} else {
System.out.println("Interval: " + intervalSeconds + " seconds");
}
if (verboseOutput) {
System.out.println("Verbose output enabled");
}
sendTrackPoints(trackPoints, reittiUrl, apiToken, intervalSeconds, useOriginalTime);
sendTrackPoints(trackPoints, reittiUrl, apiToken, intervalSeconds, useOriginalTime, verboseOutput);
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
@@ -145,7 +153,7 @@ public class GpxSender {
return trackPoints;
}
private static void sendTrackPoints(List<TrackPoint> trackPoints, String reittiUrl, String apiToken, double intervalSeconds, boolean useOriginalTime) throws Exception {
private static void sendTrackPoints(List<TrackPoint> trackPoints, String reittiUrl, String apiToken, double intervalSeconds, boolean useOriginalTime, boolean verboseOutput) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
@@ -170,7 +178,6 @@ public class GpxSender {
} else if (trackPoints.get(i - 1).timestamp != null && point.timestamp != null) {
// Calculate time difference from previous point and add to previous adjusted time
TrackPoint prevPoint = trackPoints.get(i-1);
long durationFromPrev = point.timestamp.getEpochSecond() - prevPoint.timestamp.getEpochSecond();
adjustedTime = startTime.plusMillis((long) (i * intervalSeconds * 1000));
} else {
// Fallback: distribute points evenly from start time
@@ -196,16 +203,44 @@ public class GpxSender {
post.setEntity(new StringEntity(json, ContentType.APPLICATION_JSON));
System.out.printf("Sending point %d/%d: lat=%.6f, lon=%.6f, time=%s%n",
i + 1, trackPoints.size(), point.latitude, point.longitude,
adjustedTime.toString());
i + 1, trackPoints.size(), point.latitude, point.longitude, adjustedTime);
try {
httpClient.execute(post, response -> {
int statusCode = response.getCode();
if (statusCode >= 200 && statusCode < 300) {
System.out.println("✓ Sent successfully");
// Try to read and pretty-print the response body if it exists and verbose is enabled
if (verboseOutput) {
try {
String responseBody = new String(response.getEntity().getContent().readAllBytes());
if (!responseBody.isEmpty()) {
Object jsonResponse = objectMapper.readValue(responseBody, Object.class);
String prettyJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonResponse);
System.out.println("Response:");
System.out.println(prettyJson);
}
} catch (Exception e) {
// If we can't parse as JSON, just print the raw response
try {
String rawResponse = new String(response.getEntity().getContent().readAllBytes());
if (!rawResponse.isEmpty()) {
System.out.println("Response: " + rawResponse);
}
} catch (Exception ex) {
// Ignore if we can't read the response
}
}
}
} else {
System.err.println("✗ Failed with status: " + statusCode);
// Print error response if available
try {
String errorBody = new String(response.getEntity().getContent().readAllBytes());
System.err.println("Error response: " + errorBody);
} catch (Exception e) {
// Ignore if we can't read the error body
}
}
return null;
});

View File

@@ -2,9 +2,7 @@ package com.dedicatedcode.reitti.controller;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.service.integration.ReittiIntegrationService;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.http.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
@@ -15,36 +13,21 @@ import java.util.concurrent.TimeUnit;
@Controller
@RequestMapping("/reitti-integration")
public class ReittiIntegrationController {
private final JdbcTemplate jdbcTemplate;
private final ReittiIntegrationService reittiIntegrationService;
public ReittiIntegrationController(JdbcTemplate jdbcTemplate, ReittiIntegrationService reittiIntegrationService) {
this.jdbcTemplate = jdbcTemplate;
public ReittiIntegrationController(ReittiIntegrationService reittiIntegrationService) {
this.reittiIntegrationService = reittiIntegrationService;
}
@GetMapping("/avatar/{integrationId}")
public ResponseEntity<byte[]> getAvatar(@PathVariable Long integrationId) {
Map<String, Object> result;
try {
result = jdbcTemplate.queryForMap(
"SELECT mime_type, binary_data FROM remote_user_info WHERE integration_id = ?",
integrationId
);
} catch (EmptyResultDataAccessException ignored) {
return ResponseEntity.notFound().build();
}
String contentType = (String) result.get("mime_type");
byte[] imageData = (byte[]) result.get("binary_data");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(contentType));
headers.setContentLength(imageData.length);
headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS));
return new ResponseEntity<>(imageData, headers, HttpStatus.OK);
public ResponseEntity<byte[]> getAvatar(@AuthenticationPrincipal User user, @PathVariable Long integrationId) {
return this.reittiIntegrationService.getAvatar(user, integrationId).map(avatarData -> {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(avatarData.mimeType()));
headers.setContentLength(avatarData.imageData().length);
headers.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS));
return new ResponseEntity<>(avatarData.imageData(), headers, HttpStatus.OK);
}).orElse(ResponseEntity.notFound().cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)).build());
}

View File

@@ -28,6 +28,8 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -135,8 +137,15 @@ public class OwntracksIngestionApiController {
try {
ReittiRemoteInfo info = reittiIntegrationService.getInfo(integration);
String tid = generateTid(info.userInfo().username());
friendsData.add(new OwntracksFriendResponse(tid, info.userInfo().displayName(), null, null));
} catch (RequestFailedException | RequestTemporaryFailedException e) {
OwntracksFriendResponse owntracksFriendResponse = reittiIntegrationService.getAvatar(user, integration.getId())
.map(avatarData -> new OwntracksFriendResponse(tid, info.userInfo().displayName(), avatarData.imageData(), avatarData.mimeType()))
.orElse(new OwntracksFriendResponse(tid, info.userInfo().displayName(), null, null));
friendsData.add(owntracksFriendResponse);
Optional<LocationPoint> latestLocation = reittiIntegrationService.findLatest(user, integration.getId());
latestLocation.ifPresent(location -> friendsData.add(new OwntracksFriendResponse(tid, info.userInfo().displayName(), location.getLatitude(), location.getLongitude(), Instant.parse(location.getTimestamp()).getEpochSecond())));
} catch (RequestFailedException | RequestTemporaryFailedException e) {
logger.warn("Couldn't fetch info for integration {}", integration.getId(), e);
}
}

View File

@@ -2,6 +2,7 @@ package com.dedicatedcode.reitti.service.integration;
import com.dedicatedcode.reitti.dto.*;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
import com.dedicatedcode.reitti.model.integration.ReittiIntegration;
import com.dedicatedcode.reitti.model.security.RemoteUser;
@@ -15,11 +16,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.http.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import javax.xml.stream.Location;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@@ -37,50 +41,24 @@ public class ReittiIntegrationService {
private final String advertiseUri;
private final ReittiIntegrationJdbcService jdbcService;
private final JdbcTemplate jdbcTemplate;
private final RestTemplate restTemplate;
private final AvatarService avatarService;
private final Map<Long, String> integrationSubscriptions = new ConcurrentHashMap<>();
private final Map<String, Long> userForSubscriptions = new ConcurrentHashMap<>();
public ReittiIntegrationService(@Value("${reitti.server.advertise-uri}") String advertiseUri, ReittiIntegrationJdbcService jdbcService,
public ReittiIntegrationService(@Value("${reitti.server.advertise-uri}") String advertiseUri,
ReittiIntegrationJdbcService jdbcService,
JdbcTemplate jdbcTemplate,
RestTemplate restTemplate,
AvatarService avatarService) {
this.advertiseUri = advertiseUri;
this.jdbcService = jdbcService;
this.jdbcTemplate = jdbcTemplate;
this.restTemplate = restTemplate;
this.avatarService = avatarService;
}
public List<UserTimelineData> getTimelineData(User user, LocalDate selectedDate, ZoneId userTimezone) {
return this.jdbcService
.findAllByUser(user)
.stream().filter(integration -> integration.isEnabled() && VALID_INTEGRATION_STATUS.contains(integration.getStatus()))
.map(integration -> {
log.debug("Fetching user timeline data for [{}]", integration);
try {
RemoteUser remoteUser = handleRemoteUser(integration);
List<TimelineEntry> timelineEntries = loadTimeLineEntries(integration, selectedDate, userTimezone);
integration = update(integration.withStatus(ReittiIntegration.Status.ACTIVE).withLastUsed(LocalDateTime.now()));
return new UserTimelineData("remote:" + integration.getId(),
remoteUser.getDisplayName(),
this.avatarService.generateInitials(remoteUser.getDisplayName()),
"/reitti-integration/avatar/" + integration.getId(),
integration.getColor(),
timelineEntries,
String.format("/reitti-integration/raw-location-points/%d?date=%s&timezone=%s", integration.getId(), selectedDate, userTimezone),
String.format("/reitti-integration/visits/%d?date=%s&timezone=%s", integration.getId(), selectedDate, userTimezone));
} catch (RequestFailedException e) {
log.error("couldn't fetch user info for [{}]", integration, e);
update(integration.withStatus(ReittiIntegration.Status.FAILED).withLastUsed(LocalDateTime.now()).withEnabled(false));
} catch (RequestTemporaryFailedException e) {
log.warn("couldn't temporarily fetch user info for [{}]", integration, e);
update(integration.withStatus(ReittiIntegration.Status.RECOVERABLE).withLastUsed(LocalDateTime.now()));
}
return null;
}).toList();
}
public List<UserTimelineData> getTimelineDataRange(User user, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) {
return this.jdbcService
.findAllByUser(user)
@@ -146,6 +124,24 @@ public class ReittiIntegrationService {
}
}
public Optional<AvatarService.AvatarData> getAvatar(User user, Long integrationId) {
Map<String, Object> result;
try {
result = jdbcTemplate.queryForMap(
"SELECT mime_type, binary_data FROM remote_user_info WHERE integration_id = ?",
integrationId
);
} catch (EmptyResultDataAccessException ignored) {
return Optional.empty();
}
String contentType = (String) result.get("mime_type");
byte[] imageData = (byte[]) result.get("binary_data");
return Optional.of(new AvatarService.AvatarData(contentType,imageData, -1));
}
public ProcessedVisitResponse getVisits(User user, Long integrationId, String startDate, String endDate, Integer zoom, String timezone) {
return this.jdbcService
.findByIdAndUser(integrationId,user)
@@ -192,6 +188,51 @@ public class ReittiIntegrationService {
.filter(Objects::nonNull)
.findFirst().orElse(null);
}
public Optional<LocationPoint> findLatest(User user, Long integrationId) {
return this.jdbcService
.findByIdAndUser(integrationId, user)
.filter(integration -> integration.isEnabled() && VALID_INTEGRATION_STATUS.contains(integration.getStatus()))
.map(integration -> {
log.debug("Fetching latest location ata for [{}]", integration);
try {
HttpHeaders headers = new HttpHeaders();
headers.set("X-API-TOKEN", integration.getToken());
HttpEntity<String> entity = new HttpEntity<>(headers);
String rawLocationDataUrl = integration.getUrl().endsWith("/") ?
integration.getUrl() + "api/v1/latest-location" :
integration.getUrl() + "/api/v1/latest-location";
ResponseEntity<Map> remoteResponse = restTemplate.exchange(
rawLocationDataUrl,
HttpMethod.GET,
entity,
Map.class
);
if (remoteResponse.getStatusCode().is2xxSuccessful() && remoteResponse.getStatusCode().is2xxSuccessful() && remoteResponse.getBody() != null && remoteResponse.getBody().containsKey("hasLocation")) {
update(integration.withStatus(ReittiIntegration.Status.ACTIVE).withLastUsed(LocalDateTime.now()));
if (!remoteResponse.getBody().get("hasLocation").equals(true)) {
return null;
} else {
return parseLocationPoint(remoteResponse.getBody().get("point"));
}
} else if (remoteResponse.getStatusCode().is4xxClientError()) {
throw new RequestFailedException(rawLocationDataUrl, remoteResponse.getStatusCode(), remoteResponse.getBody());
} else {
throw new RequestTemporaryFailedException(rawLocationDataUrl, remoteResponse.getStatusCode(), remoteResponse.getBody());
}
} catch (RequestFailedException e) {
log.error("couldn't fetch user info for [{}]", integration, e);
update(integration.withStatus(ReittiIntegration.Status.FAILED).withLastUsed(LocalDateTime.now()).withEnabled(false));
} catch (RequestTemporaryFailedException e) {
log.warn("couldn't temporarily fetch user info for [{}]", integration, e);
update(integration.withStatus(ReittiIntegration.Status.RECOVERABLE).withLastUsed(LocalDateTime.now()));
}
return null;
});
}
public List<LocationPoint> getRawLocationData(User user, Long integrationId, String startDate, String endDate, Integer zoom, String timezone) {
return this.jdbcService
.findByIdAndUser(integrationId,user)
@@ -249,36 +290,6 @@ public class ReittiIntegrationService {
return integration;
}
private List<TimelineEntry> loadTimeLineEntries(ReittiIntegration integration, LocalDate selectedDate, ZoneId userTimezone) throws RequestFailedException, RequestTemporaryFailedException {
HttpHeaders headers = new HttpHeaders();
headers.set("X-API-TOKEN", integration.getToken());
HttpEntity<String> entity = new HttpEntity<>(headers);
String timelineUrl = integration.getUrl().endsWith("/") ?
integration.getUrl() + "api/v1/reitti-integration/timeline?date={date}&timezone={timezone}" :
integration.getUrl() + "/api/v1/reitti-integration/timeline?date={date}&timezone={timezone}";
ParameterizedTypeReference<List<TimelineEntry>> typeRef = new ParameterizedTypeReference<>() {};
ResponseEntity<List<TimelineEntry>> remoteResponse = restTemplate.exchange(
timelineUrl,
HttpMethod.GET,
entity,
typeRef,
selectedDate,
userTimezone.getId()
);
if (remoteResponse.getStatusCode().is2xxSuccessful()) {
return remoteResponse.getBody();
} else if (remoteResponse.getStatusCode().is4xxClientError()) {
throw new RequestFailedException(timelineUrl, remoteResponse.getStatusCode(), remoteResponse.getBody());
} else {
throw new RequestTemporaryFailedException(timelineUrl, remoteResponse.getStatusCode(), remoteResponse.getBody());
}
}
private List<TimelineEntry> loadTimeLineEntriesRange(ReittiIntegration integration, LocalDate startDate, LocalDate endDate, ZoneId userTimezone) throws RequestFailedException, RequestTemporaryFailedException {
HttpHeaders headers = new HttpHeaders();
@@ -289,7 +300,6 @@ public class ReittiIntegrationService {
integration.getUrl() + "api/v1/reitti-integration/timeline?startDate={startDate}&endDate={endDate}&timezone={timezone}" :
integration.getUrl() + "/api/v1/reitti-integration/timeline?startDate={startDate}&endDate={endDate}&timezone={timezone}";
ParameterizedTypeReference<List<TimelineEntry>> typeRef = new ParameterizedTypeReference<>() {};
ResponseEntity<List<TimelineEntry>> remoteResponse = restTemplate.exchange(
timelineUrl,
@@ -568,4 +578,24 @@ public class ReittiIntegrationService {
return 0;
}
private LocationPoint parseLocationPoint(Object locationObj) {
if (locationObj == null) {
return null;
}
Map<String, Object> locationMap = (Map<String, Object>) locationObj;
LocationPoint locationPoint = new LocationPoint();
locationPoint.setLatitude(getDoubleValue(locationMap, "latitude"));
locationPoint.setLongitude(getDoubleValue(locationMap, "longitude"));
locationPoint.setTimestamp((String) locationMap.get("timestamp"));
locationPoint.setAccuracyMeters(getDoubleValue(locationMap, "accuracyMeters"));
if (locationMap.containsKey("elevationMeters")) {
locationPoint.setElevationMeters(getDoubleValue(locationMap, "elevationMeters"));
}
return locationPoint;
}
}