1 add gpx ingest and import (#9)

added GPX data import
This commit is contained in:
Daniel Graf
2025-05-30 20:09:48 +02:00
committed by GitHub
parent 9281dee297
commit 34a02f052d
20 changed files with 605 additions and 9750 deletions

28
pom.xml
View File

@@ -15,6 +15,7 @@
<description>Reitti application</description>
<properties>
<java.version>24</java.version>
<argLine/>
</properties>
<dependencies>
<dependency>
@@ -81,20 +82,31 @@
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
</plugin>
</plugins>

View File

@@ -10,6 +10,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -39,10 +40,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
Optional<User> user = apiTokenService.getUserByToken(authHeader);
if (user.isPresent()) {
Authentication authenticationToken = new UsernamePasswordAuthenticationToken(
user.get().getUsername(),
user.get().getPassword(),
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
User authenticatedUser = user.get();
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
authenticatedUser,
null,
authenticatedUser.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

View File

@@ -4,17 +4,17 @@ import com.dedicatedcode.reitti.dto.TimelineResponse;
import com.dedicatedcode.reitti.model.ApiToken;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.service.ApiTokenService;
import com.dedicatedcode.reitti.service.PlaceService;
import com.dedicatedcode.reitti.service.QueueStatsService;
import com.dedicatedcode.reitti.service.UserService;
import com.dedicatedcode.reitti.service.*;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -28,13 +28,16 @@ public class SettingsController {
private final UserService userService;
private final QueueStatsService queueStatsService;
private final PlaceService placeService;
private final ImportHandler importHandler;
public SettingsController(ApiTokenService apiTokenService, UserService userService,
QueueStatsService queueStatsService, PlaceService placeService) {
QueueStatsService queueStatsService, PlaceService placeService,
ImportHandler importHandler) {
this.apiTokenService = apiTokenService;
this.userService = userService;
this.queueStatsService = queueStatsService;
this.placeService = placeService;
this.importHandler = importHandler;
}
// HTMX endpoints for the settings overlay
@@ -220,4 +223,73 @@ public class SettingsController {
model.addAttribute("queueStats", queueStatsService.getQueueStats());
return "fragments/settings :: queue-stats-content";
}
@GetMapping("/file-upload-content")
public String getDataImportContent() {
return "fragments/settings :: file-upload-content";
}
@PostMapping("/import/gpx")
public String importGpx(@RequestParam("file") MultipartFile file,
Authentication authentication,
Model model) {
String username = authentication.getName();
if (file.isEmpty()) {
model.addAttribute("uploadErrorMessage", "File is empty");
return "fragments/settings :: file-upload-content";
}
if (!file.getOriginalFilename().endsWith(".gpx")) {
model.addAttribute("uploadErrorMessage", "Only GPX files are supported");
return "fragments/settings :: file-upload-content";
}
try (InputStream inputStream = file.getInputStream()) {
Map<String, Object> result = importHandler.importGpx(inputStream, username);
if ((Boolean) result.get("success")) {
model.addAttribute("uploadSuccessMessage", result.get("message"));
} else {
model.addAttribute("uploadErrorMessage", result.get("error"));
}
return "fragments/settings :: file-upload-content";
} catch (IOException e) {
model.addAttribute("uploadErrorMessage", "Error processing file: " + e.getMessage());
return "fragments/settings :: file-upload-content";
}
}
@PostMapping("/import/google-takeout")
public String importGoogleTakeout(@RequestParam("file") MultipartFile file,
Authentication authentication,
Model model) {
String username = authentication.getName();
if (file.isEmpty()) {
model.addAttribute("uploadErrorMessage", "File is empty");
return "fragments/settings :: file-upload-content";
}
if (!file.getOriginalFilename().endsWith(".json")) {
model.addAttribute("uploadErrorMessage", "Only JSON files are supported");
return "fragments/settings :: file-upload-content";
}
try (InputStream inputStream = file.getInputStream()) {
Map<String, Object> result = importHandler.importGoogleTakeout(inputStream, username);
if ((Boolean) result.get("success")) {
model.addAttribute("uploadSuccessMessage", result.get("message"));
} else {
model.addAttribute("uploadErrorMessage", result.get("error"));
}
return "fragments/settings :: file-upload-content";
} catch (IOException e) {
model.addAttribute("uploadErrorMessage", "Error processing file: " + e.getMessage());
return "fragments/settings :: file-upload-content";
}
}
}

View File

@@ -3,13 +3,7 @@ package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.service.ApiTokenService;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.dedicatedcode.reitti.service.ImportHandler;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -17,57 +11,44 @@ import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
@RestController
@RequestMapping("/api/v1")
public class LocationDataApiController {
private static final Logger logger = LoggerFactory.getLogger(LocationDataApiController.class);
private static final int BATCH_SIZE = 100; // Process locations in batches of 100
private final ApiTokenService apiTokenService;
private final ObjectMapper objectMapper;
private final RabbitTemplate rabbitTemplate;
private final ImportHandler importHandler;
@Autowired
public LocationDataApiController(
ApiTokenService apiTokenService,
ObjectMapper objectMapper,
RabbitTemplate rabbitTemplate) {
this.apiTokenService = apiTokenService;
this.objectMapper = objectMapper;
RabbitTemplate rabbitTemplate,
ImportHandler importHandler) {
this.rabbitTemplate = rabbitTemplate;
this.importHandler = importHandler;
}
@PostMapping("/location-data")
public ResponseEntity<?> receiveLocationData(
@RequestHeader("X-API-Token") String apiToken,
@Valid @RequestBody LocationDataRequest request) {
// Authenticate using the API token
User user = apiTokenService.getUserByToken(apiToken)
.orElse(null);
if (user == null) {
logger.warn("Invalid API token used: {}", apiToken);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid API token"));
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetails user = (UserDetails) authentication.getPrincipal();
try {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
user.getId(),
user.getUsername(),
user.getUsername(),
request.getPoints()
);
@@ -95,153 +76,61 @@ public class LocationDataApiController {
@PostMapping("/import/google-takeout")
public ResponseEntity<?> importGoogleTakeout(
@RequestHeader("X-API-Token") String apiToken,
@RequestParam("file") MultipartFile file) {
// Authenticate using the API token
User user = apiTokenService.getUserByToken(apiToken)
.orElse(null);
if (user == null) {
logger.warn("Invalid API token used: {}", apiToken);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid API token"));
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetails user = (UserDetails) authentication.getPrincipal();
if (file.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "File is empty"));
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "File is empty"));
}
if (!file.getOriginalFilename().endsWith(".json")) {
return ResponseEntity.badRequest().body(Map.of("error", "Only JSON files are supported"));
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Only JSON files are supported"));
}
AtomicInteger processedCount = new AtomicInteger(0);
try (InputStream inputStream = file.getInputStream()) {
// Use Jackson's streaming API to process the file
JsonFactory factory = objectMapper.getFactory();
JsonParser parser = factory.createParser(inputStream);
// Find the "locations" array
while (parser.nextToken() != null) {
if (parser.getCurrentToken() == JsonToken.FIELD_NAME &&
"locations".equals(parser.getCurrentName())) {
// Move to the array
parser.nextToken(); // Should be START_ARRAY
if (parser.getCurrentToken() != JsonToken.START_ARRAY) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid format: 'locations' is not an array"));
}
List<LocationDataRequest.LocationPoint> batch = new ArrayList<>(BATCH_SIZE);
// Process each location in the array
while (parser.nextToken() != JsonToken.END_ARRAY) {
if (parser.getCurrentToken() == JsonToken.START_OBJECT) {
// Parse the location object
JsonNode locationNode = objectMapper.readTree(parser);
try {
LocationDataRequest.LocationPoint point = convertGoogleTakeoutLocation(locationNode);
if (point != null) {
batch.add(point);
processedCount.incrementAndGet();
// Process in batches to avoid memory issues
if (batch.size() >= BATCH_SIZE) {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
user.getId(),
user.getUsername(),
new ArrayList<>(batch) // Create a copy to avoid reference issues
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
logger.info("Queued batch of {} locations for processing", batch.size());
batch.clear();
}
}
} catch (Exception e) {
logger.warn("Error processing location entry: {}", e.getMessage());
// Continue with next location
}
}
}
// Process any remaining locations
if (!batch.isEmpty()) {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
user.getId(),
user.getUsername(),
new ArrayList<>(batch) // Create a copy to avoid reference issues
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
logger.info("Queued final batch of {} locations for processing", batch.size());
}
break; // We've processed the locations array, no need to continue
}
Map<String, Object> result = importHandler.importGoogleTakeout(inputStream, user.getUsername());
if ((Boolean) result.get("success")) {
return ResponseEntity.accepted().body(result);
} else {
return ResponseEntity.badRequest().body(result);
}
logger.info("Successfully imported and queued {} location points from Google Takeout for user {}",
processedCount.get(), user.getUsername());
return ResponseEntity.accepted().body(Map.of(
"success", true,
"message", "Successfully queued " + processedCount.get() + " location points for processing",
"pointsReceived", processedCount.get()
));
} catch (IOException e) {
logger.error("Error processing Google Takeout file", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Error processing Google Takeout file: " + e.getMessage()));
.body(Map.of("success", false, "error", "Error processing file: " + e.getMessage()));
}
}
/**
* Converts a Google Takeout location entry to our LocationPoint format
*/
private LocationDataRequest.LocationPoint convertGoogleTakeoutLocation(JsonNode locationNode) {
// Check if we have the required fields
if (!locationNode.has("latitudeE7") ||
!locationNode.has("longitudeE7") ||
!locationNode.has("timestamp")) {
return null;
@PostMapping("/import/gpx")
public ResponseEntity<?> importGpx(
@RequestParam("file") MultipartFile file) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetails user = (UserDetails) authentication.getPrincipal();
if (file.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "File is empty"));
}
LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
// Convert latitudeE7 and longitudeE7 to standard decimal format
// Google stores these as integers with 7 decimal places of precision
double latitude = locationNode.get("latitudeE7").asDouble() / 10000000.0;
double longitude = locationNode.get("longitudeE7").asDouble() / 10000000.0;
point.setLatitude(latitude);
point.setLongitude(longitude);
point.setTimestamp(locationNode.get("timestamp").asText());
// Set accuracy if available
if (locationNode.has("accuracy")) {
point.setAccuracyMeters(locationNode.get("accuracy").asDouble());
} else {
point.setAccuracyMeters(100.0);
if (!file.getOriginalFilename().endsWith(".gpx")) {
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Only GPX files are supported"));
}
return point;
try (InputStream inputStream = file.getInputStream()) {
Map<String, Object> result = importHandler.importGpx(inputStream, user.getUsername());
if ((Boolean) result.get("success")) {
return ResponseEntity.accepted().body(result);
} else {
return ResponseEntity.badRequest().body(result);
}
} catch (IOException e) {
logger.error("Error processing GPX file", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("success", false, "error", "Error processing file: " + e.getMessage()));
}
}
}

View File

@@ -9,26 +9,19 @@ import java.time.Instant;
import java.util.List;
public class LocationDataEvent implements Serializable {
private final Long userId;
private final String username;
private final List<LocationDataRequest.LocationPoint> points;
private final Instant receivedAt;
@JsonCreator
public LocationDataEvent(
@JsonProperty("userId") Long userId,
@JsonProperty("username") String username,
@JsonProperty("points") List<LocationDataRequest.LocationPoint> points) {
this.userId = userId;
this.username = username;
this.points = points;
this.receivedAt = Instant.now();
}
public Long getUserId() {
return userId;
}
public String getUsername() {
return username;
}

View File

@@ -1,7 +1,7 @@
package com.dedicatedcode.reitti.event;
public class MergeVisitEvent {
private Long userId;
private String userName;
private Long startTime;
private Long endTime;
@@ -9,19 +9,18 @@ public class MergeVisitEvent {
public MergeVisitEvent() {
}
public MergeVisitEvent(Long userId, Long startTime, Long endTime) {
this.userId = userId;
public MergeVisitEvent(String userName, Long startTime, Long endTime) {
this.userName = userName;
this.startTime = startTime;
this.endTime = endTime;
}
// Getters and setters
public Long getUserId() {
return userId;
public String getUserName() {
return userName;
}
public void setUserId(Long userId) {
this.userId = userId;
public void setUserName(String userName) {
this.userName = userName;
}
public Long getStartTime() {

View File

@@ -1,14 +1,19 @@
package com.dedicatedcode.reitti.model;
import jakarta.persistence.*;
import org.springframework.messaging.simp.user.DefaultUserDestinationResolver;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
@Entity
@Table(name = "users")
public class User {
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@@ -52,6 +57,26 @@ public class User {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public void setUsername(String username) {
this.username = username;
}
@@ -63,7 +88,12 @@ public class User {
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("USER"));
}
public String getPassword() {
return password;
}
@@ -103,4 +133,7 @@ public class User {
public void setTrips(List<Trip> trips) {
this.trips = trips;
}
}

View File

@@ -0,0 +1,288 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
@Service
public class ImportHandler {
private static final Logger logger = LoggerFactory.getLogger(ImportHandler.class);
private static final int BATCH_SIZE = 100; // Process locations in batches of 100
private final ObjectMapper objectMapper;
private final RabbitTemplate rabbitTemplate;
@Autowired
public ImportHandler(
ObjectMapper objectMapper,
RabbitTemplate rabbitTemplate) {
this.objectMapper = objectMapper;
this.rabbitTemplate = rabbitTemplate;
}
public Map<String, Object> importGoogleTakeout(InputStream inputStream, String username) {
AtomicInteger processedCount = new AtomicInteger(0);
try {
// Use Jackson's streaming API to process the file
JsonFactory factory = objectMapper.getFactory();
JsonParser parser = factory.createParser(inputStream);
// Find the "locations" array
while (parser.nextToken() != null) {
if (parser.getCurrentToken() == JsonToken.FIELD_NAME &&
"locations".equals(parser.getCurrentName())) {
// Move to the array
parser.nextToken(); // Should be START_ARRAY
if (parser.getCurrentToken() != JsonToken.START_ARRAY) {
return Map.of("success", false, "error", "Invalid format: 'locations' is not an array");
}
List<LocationDataRequest.LocationPoint> batch = new ArrayList<>(BATCH_SIZE);
// Process each location in the array
while (parser.nextToken() != JsonToken.END_ARRAY) {
if (parser.getCurrentToken() == JsonToken.START_OBJECT) {
// Parse the location object
JsonNode locationNode = objectMapper.readTree(parser);
try {
LocationDataRequest.LocationPoint point = convertGoogleTakeoutLocation(locationNode);
if (point != null) {
batch.add(point);
processedCount.incrementAndGet();
// Process in batches to avoid memory issues
if (batch.size() >= BATCH_SIZE) {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
username,
new ArrayList<>(batch) // Create a copy to avoid reference issues
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
logger.info("Queued batch of {} locations for processing", batch.size());
batch.clear();
}
}
} catch (Exception e) {
logger.warn("Error processing location entry: {}", e.getMessage());
// Continue with next location
}
}
}
// Process any remaining locations
if (!batch.isEmpty()) {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
username,
new ArrayList<>(batch) // Create a copy to avoid reference issues
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
logger.info("Queued final batch of {} locations for processing", batch.size());
}
break; // We've processed the locations array, no need to continue
}
}
logger.info("Successfully imported and queued {} location points from Google Takeout for user {}",
processedCount.get(), username);
return Map.of(
"success", true,
"message", "Successfully queued " + processedCount.get() + " location points for processing",
"pointsReceived", processedCount.get()
);
} catch (IOException e) {
logger.error("Error processing Google Takeout file", e);
return Map.of("success", false, "error", "Error processing Google Takeout file: " + e.getMessage());
}
}
/**
* Converts a Google Takeout location entry to our LocationPoint format
*/
private LocationDataRequest.LocationPoint convertGoogleTakeoutLocation(JsonNode locationNode) {
// Check if we have the required fields
if (!locationNode.has("latitudeE7") ||
!locationNode.has("longitudeE7") ||
!locationNode.has("timestamp")) {
return null;
}
LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
// Convert latitudeE7 and longitudeE7 to standard decimal format
// Google stores these as integers with 7 decimal places of precision
double latitude = locationNode.get("latitudeE7").asDouble() / 10000000.0;
double longitude = locationNode.get("longitudeE7").asDouble() / 10000000.0;
point.setLatitude(latitude);
point.setLongitude(longitude);
point.setTimestamp(locationNode.get("timestamp").asText());
// Set accuracy if available
if (locationNode.has("accuracy")) {
point.setAccuracyMeters(locationNode.get("accuracy").asDouble());
} else {
point.setAccuracyMeters(100.0);
}
return point;
}
public Map<String, Object> importGpx(InputStream inputStream, String username) {
AtomicInteger processedCount = new AtomicInteger(0);
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(inputStream);
// Normalize the XML structure
document.getDocumentElement().normalize();
// Get all track points (trkpt) from the GPX file
NodeList trackPoints = document.getElementsByTagName("trkpt");
List<LocationDataRequest.LocationPoint> batch = new ArrayList<>(BATCH_SIZE);
// Process each track point
for (int i = 0; i < trackPoints.getLength(); i++) {
Element trackPoint = (Element) trackPoints.item(i);
try {
LocationDataRequest.LocationPoint point = convertGpxTrackPoint(trackPoint);
if (point != null) {
batch.add(point);
processedCount.incrementAndGet();
// Process in batches to avoid memory issues
if (batch.size() >= BATCH_SIZE) {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
username,
new ArrayList<>(batch) // Create a copy to avoid reference issues
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
logger.info("Queued batch of {} locations for processing", batch.size());
batch.clear();
}
}
} catch (Exception e) {
logger.warn("Error processing GPX track point: {}", e.getMessage());
// Continue with next point
}
}
// Process any remaining locations
if (!batch.isEmpty()) {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
username,
new ArrayList<>(batch) // Create a copy to avoid reference issues
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
logger.info("Queued final batch of {} locations for processing", batch.size());
}
logger.info("Successfully imported and queued {} location points from GPX file for user {}",
processedCount.get(), username);
return Map.of(
"success", true,
"message", "Successfully queued " + processedCount.get() + " location points for processing",
"pointsReceived", processedCount.get()
);
} catch (Exception e) {
logger.error("Error processing GPX file", e);
return Map.of("success", false, "error", "Error processing GPX file: " + e.getMessage());
}
}
/**
* Converts a GPX track point to our LocationPoint format
*/
private LocationDataRequest.LocationPoint convertGpxTrackPoint(Element trackPoint) {
// Check if we have the required attributes
if (!trackPoint.hasAttribute("lat") || !trackPoint.hasAttribute("lon")) {
return null;
}
LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
// Get latitude and longitude
double latitude = Double.parseDouble(trackPoint.getAttribute("lat"));
double longitude = Double.parseDouble(trackPoint.getAttribute("lon"));
point.setLatitude(latitude);
point.setLongitude(longitude);
// Get timestamp from the time element
NodeList timeElements = trackPoint.getElementsByTagName("time");
if (timeElements.getLength() > 0) {
String timeStr = timeElements.item(0).getTextContent();
point.setTimestamp(timeStr);
} else {
return null;
}
// Set accuracy - GPX doesn't typically include accuracy, so use a default
point.setAccuracyMeters(10.0); // Default accuracy of 10 meters
return point;
}
}

View File

@@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -30,7 +31,7 @@ public class LocationDataService {
public List<RawLocationPoint> processLocationData(User user, List<LocationDataRequest.LocationPoint> points) {
List<RawLocationPoint> savedPoints = new ArrayList<>();
int duplicatesSkipped = 0;
for (LocationDataRequest.LocationPoint point : points) {
try {
Optional<RawLocationPoint> savedPoint = processSingleLocationPoint(user, point);
@@ -44,26 +45,27 @@ public class LocationDataService {
// Continue with next point
}
}
if (duplicatesSkipped > 0) {
logger.info("Skipped {} duplicate points for user {}", duplicatesSkipped, user.getUsername());
}
return savedPoints;
}
@Transactional
public Optional<RawLocationPoint> processSingleLocationPoint(User user, LocationDataRequest.LocationPoint point) {
Instant timestamp = Instant.parse(point.getTimestamp());
ZonedDateTime parse = ZonedDateTime.parse(point.getTimestamp());
Instant timestamp = parse.toInstant();
// Check if a point with this timestamp already exists for this user
Optional<RawLocationPoint> existingPoint = rawLocationPointRepository.findByUserAndTimestamp(user, timestamp);
if (existingPoint.isPresent()) {
logger.debug("Skipping duplicate point at timestamp {} for user {}", timestamp, user.getUsername());
return Optional.empty(); // Return empty to indicate no new point was saved
}
RawLocationPoint locationPoint = new RawLocationPoint();
locationPoint.setUser(user);
locationPoint.setLatitude(point.getLatitude());
@@ -71,7 +73,7 @@ public class LocationDataService {
locationPoint.setTimestamp(timestamp);
locationPoint.setAccuracyMeters(point.getAccuracyMeters());
locationPoint.setActivityProvided(point.getActivity());
return Optional.of(rawLocationPointRepository.save(locationPoint));
}
}

View File

@@ -51,10 +51,10 @@ public class LocationProcessingPipeline {
logger.debug("Starting processing pipeline for user {} with {} points",
event.getUsername(), event.getPoints().size());
Optional<User> userOpt = userRepository.findById(event.getUserId());
Optional<User> userOpt = userRepository.findByUsername(event.getUsername());
if (userOpt.isEmpty()) {
logger.warn("User not found for ID: {}", event.getUserId());
logger.warn("User not found for name: {}", event.getUsername ());
return;
}
@@ -84,8 +84,8 @@ public class LocationProcessingPipeline {
Instant endTime = savedPoints.stream().map(RawLocationPoint::getTimestamp).max(Instant::compareTo).orElse(Instant.now());
long searchStart = startTime.minus(tripVisitMergeTimeRange, ChronoUnit.DAYS).toEpochMilli();
long searchEnd = endTime.plus(tripVisitMergeTimeRange, ChronoUnit.DAYS).toEpochMilli();
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getId(), searchStart, searchEnd));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getId(), searchStart, searchEnd));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), searchStart, searchEnd));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), searchStart, searchEnd));
}
logger.info("Completed processing pipeline for user {}", user.getUsername());

View File

@@ -42,10 +42,9 @@ public class TripDetectionService {
@Transactional
@RabbitListener(queues = RabbitMQConfig.DETECT_TRIP_QUEUE, concurrency = "1-16")
public void detectTripsForUser(MergeVisitEvent event) {
Optional<User> user = this.userRepository.findById(event.getUserId());
Optional<User> user = userRepository.findByUsername(event.getUserName());
if (user.isEmpty()) {
logger.warn("User not found for ID: {}", event.getUserId());
logger.warn("User not found for userName: {}", event.getUserName());
return;
}
logger.info("Detecting trips for user: {}", user.get().getUsername());

View File

@@ -40,9 +40,9 @@ public class TripMergingService {
@Transactional
@RabbitListener(queues = RabbitMQConfig.MERGE_TRIP_QUEUE)
public void mergeDuplicateTripsForUser(MergeVisitEvent event) {
Optional<User> user = this.userRepository.findById(event.getUserId());
Optional<User> user = userRepository.findByUsername(event.getUserName());
if (user.isEmpty()) {
logger.warn("User {} not found. Skipping duplicate trip merging.", event.getUserId());
logger.warn("User not found for userName: {}", event.getUserName());
return;
}
logger.info("Merging duplicate trips for user: {}", user.get().getUsername());

View File

@@ -32,8 +32,8 @@ public class VisitMergingRunner {
public void run() {
userService.getAllUsers().forEach(user -> {
logger.info("Schedule visit merging process for user {}", user.getUsername());
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getId(), null, null));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getId(), null, null));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), null, null));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), null, null));
});
}
}

View File

@@ -51,9 +51,9 @@ public class VisitMergingService {
@RabbitListener(queues = RabbitMQConfig.MERGE_VISIT_QUEUE)
@Transactional
public void mergeVisits(MergeVisitEvent event) {
Optional<User> user = userRepository.findById(event.getUserId());
Optional<User> user = userRepository.findByUsername(event.getUserName());
if (user.isEmpty()) {
logger.warn("User not found for ID: {}", event.getUserId());
logger.warn("User not found for userName: {}", event.getUserName());
return;
}
processAndMergeVisits(user.get(), event.getStartTime(), event.getEndTime());
@@ -92,7 +92,7 @@ public class VisitMergingService {
allVisits.size(), processedVisits.size(), user.getUsername());
if (!processedVisits.isEmpty() && detectTripsAfterMerging) {
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getId(), startTime, endTime));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), startTime, endTime));
}
return processedVisits;

View File

@@ -447,3 +447,23 @@ button:hover {
z-index: 50;
box-shadow: unset;
}
.upload-options progress {
display: block;
width: 100%;
margin-top: 1rem;
}
input[type=file]::file-selector-button {
background-color: var(--color-background-dark);
color: var(--color-text-white);
border: 1px solid var(--color-highlight);
padding: 10px 15px;
margin-right: 20px;
transition: .5s;
}
input[type=file]::file-selector-button:hover {
background-color: var(--color-background-dark-light);;
}

View File

@@ -185,5 +185,69 @@
</div>
</div>
<!-- File Upload Content Fragment -->
<div th:fragment="file-upload-content">
<h2>Import Location Data</h2>
<div th:if="${uploadSuccessMessage}" class="alert alert-success" style="display: block;">
<span th:text="${uploadSuccessMessage}">File uploaded successfully</span>
</div>
<div th:if="${uploadErrorMessage}" class="alert alert-danger" style="display: block;">
<span th:text="${uploadErrorMessage}">Error message</span>
</div>
<div class="upload-options">
<div class="upload-option">
<h3>GPX Files</h3>
<p class="description">
Upload GPX files from your GPS devices or tracking apps. GPX files contain waypoints,
tracks, and routes with timestamps that can be processed into your location history.
</p>
<form id="gpx-upload-form"
hx-post="/settings/import/gpx"
hx-target="#file-upload"
hx-encoding="multipart/form-data">
<div class="form-group">
<input type="file" name="file" accept=".gpx" required>
</div>
<button type="submit" class="btn">Upload GPX File</button>
</form>
<progress id='progress-gpx' value='0' max='100' style="display: none"></progress>
<script>
htmx.on('#gpx-upload-form', 'htmx:xhr:progress', function(evt) {
htmx.find('#progress-gpx').setAttribute('value', evt.detail.loaded/evt.detail.total * 100)
htmx.find('#progress-gpx').setAttribute('style', null)
});
</script>
</div>
<div class="upload-option">
<h3>Google Takeout</h3>
<p class="description">
Upload location history from Google Takeout. Export your data from Google
(takeout.google.com) and upload the Location History JSON file. This contains
your location history with timestamps and activity information.
</p>
<form id="takeout-upload-form"
hx-post="/settings/import/google-takeout"
hx-target="#file-upload"
hx-swap="innerHTML"
hx-encoding="multipart/form-data">
<div class="form-group">
<input type="file" name="file" accept=".json" required>
</div>
<button type="submit" class="btn">Upload Google Takeout</button>
</form>
<progress id='progress-google' value='0' max='100' style="display: none"></progress>
<script>
htmx.on('#takeout-upload-form', 'htmx:xhr:progress', function(evt) {
htmx.find('#progress-google').setAttribute('value', evt.detail.loaded/evt.detail.total * 100)
htmx.find('#progress-google').setAttribute('style', null)
});
</script>
</div>
</div>
</div>
</body>
</html>

View File

@@ -32,6 +32,7 @@
<div class="settings-nav-item" data-target="user-management">User Management</div>
<div class="settings-nav-item" data-target="places-management">Places</div>
<div class="settings-nav-item" data-target="job-status">Job Status</div>
<div class="settings-nav-item" data-target="file-upload">Import Data</div>
</div>
<!-- API Tokens Section -->
@@ -105,6 +106,11 @@
hx-trigger="revealed, every 5s">
<div class="htmx-indicator">Loading queue stats...</div>
</div>
<!-- File Upload Section -->
<div id="file-upload" class="settings-section">
<div class="htmx-indicator">Loading file upload options...</div>
</div>
</div>
</div>
@@ -191,6 +197,14 @@
section.setAttribute('hx-triggered', 'true');
htmx.process(section);
}
// If this is the file upload tab, load its content if not already loaded
if (target === 'file-upload' && !section.hasAttribute('hx-triggered')) {
section.setAttribute('hx-get', '/settings/file-upload-content');
section.setAttribute('hx-trigger', 'load once');
section.setAttribute('hx-triggered', 'true');
htmx.process(section);
}
}
});
});

View File

@@ -1,12 +0,0 @@
package com.dedicatedcode.reitti.controller.api;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class LocationDataApiControllerTest {
@Test
void receiveLocationData() {
}
}

View File

@@ -1,641 +0,0 @@
{
"locations": [
{
"latitudeE7": 525121303,
"longitudeE7": 134309551,
"accuracy": 25,
"source": "GPS",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:31:26.860Z"
},
{
"latitudeE7": 525122834,
"longitudeE7": 134313306,
"accuracy": 24,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:32:31.475Z"
},
{
"latitudeE7": 525117217,
"longitudeE7": 134306453,
"accuracy": 46,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:33:32.406Z"
},
{
"latitudeE7": 525121351,
"longitudeE7": 134320476,
"accuracy": 48,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:34:36.153Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:34:32.478Z"
},
{
"latitudeE7": 525121351,
"longitudeE7": 134320476,
"accuracy": 48,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:35:36.176Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:35:32.492Z"
},
{
"latitudeE7": 525121589,
"longitudeE7": 134320949,
"accuracy": 48,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:36:32.566Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:38:36.316Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:38:32.498Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:39:32.545Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:40:32.583Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:41:36.472Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:41:32.595Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:43:32.637Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:44:36.601Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:44:32.684Z"
},
{
"latitudeE7": 525121467,
"longitudeE7": 134320925,
"accuracy": 47,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:45:32.685Z"
},
{
"latitudeE7": 525121564,
"longitudeE7": 134319537,
"accuracy": 51,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:46:32.734Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "IN_VEHICLE",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:47:56.833Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:47:34.024Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:49:34.888Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:50:34.917Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:51:35.159Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:52:13.083Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:52:35.147Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:55:13.228Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:54:36.107Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:56:36.142Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:58:13.356Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T06:57:36.164Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T06:59:31.388Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:00:36.200Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:02:31.511Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:01:36.243Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:03:36.203Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:05:31.649Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:04:36.444Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:06:36.247Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:08:31.790Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:09:36.257Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:11:31.934Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:10:36.428Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:12:36.302Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:13:36.573Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:14:32.075Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:14:36.650Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:15:36.580Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:17:32.210Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:18:36.635Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:20:32.366Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:21:36.622Z"
},
{
"latitudeE7": 525121333,
"longitudeE7": 134314955,
"accuracy": 42,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:23:32.672Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:22:36.667Z"
},
{
"latitudeE7": 525122021,
"longitudeE7": 134315335,
"accuracy": 71,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:24:36.622Z"
},
{
"latitudeE7": 525117217,
"longitudeE7": 134306453,
"accuracy": 46,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:25:37.093Z"
},
{
"latitudeE7": 525122702,
"longitudeE7": 134313952,
"accuracy": 21,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:27:06.063Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:26:37.656Z"
},
{
"latitudeE7": 525117217,
"longitudeE7": 134306453,
"accuracy": 46,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:28:36.815Z"
},
{
"latitudeE7": 525121186,
"longitudeE7": 134318951,
"accuracy": 56,
"activity": [
{
"activity": [
{
"type": "IN_VEHICLE",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:29:57.980Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:29:36.836Z"
},
{
"latitudeE7": 525121318,
"longitudeE7": 134314822,
"accuracy": 76,
"activity": [
{
"activity": [
{
"type": "IN_VEHICLE",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:31:31.538Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:30:37.819Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:32:32.469Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:33:32.563Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:34:33.611Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:36:28.685Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:35:33.621Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:37:33.649Z"
},
{
"latitudeE7": 525121427,
"longitudeE7": 134314466,
"accuracy": 27,
"activity": [
{
"activity": [
{
"type": "STILL",
"confidence": 100
}
],
"timestamp": "2013-04-15T07:39:28.834Z"
}
],
"source": "WIFI",
"deviceTag": 335552189,
"timestamp": "2013-04-15T07:40:33.687Z"
}
]
}