Home | Send Feedback

Developing a self-hosted location tracker

Published: 7. November 2017  •  Updated: 5. December 2018  •  ionic, java, spring, javascript

In this blog post, I show you how to build your own self hosted location tracking solution with Ionic, Cordova, and Spring Boot.

The system consists of these three applications:

overview


Be aware that asset tracking with Google Maps is not free for every use case. Under certain circumstances, you need a Premium Plan license for Google Maps. See the website for more information:

https://mapsplatform.google.com/pricing/


Ionic/Cordova app

The Ionic application is based on the blank starter template. Then we add Cordova and the background geolocation plugin to the project. The background-geolocation plugin did not work in my Android emulator, so I had to test in on a real device.

ionic start geotracker blank
ionic cordova prepare android
ionic cordova plugin add @mauron85/cordova-plugin-background-geolocation
ionic cordova plugin add cordova-plugin-geolocation
ionic cordova run android

Then we create two services.

ng generate service LocationTracker
ng generate service ServerPush

The LocationTracker service is responsible for installing the background geolocation plugin and listening for events that this plugin emits.
The ServerPush service is responsible for sending the location data to the server.

The GUI for the app is very simplistic. It consists of only three buttons.

<ion-header>
  <ion-toolbar>
    <ion-title>
      Geo Tracker
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <ion-button (click)="start()" [disabled]="tracking" color="primary" expand="block" size="large">Start Tracking
  </ion-button>
  <ion-button (click)="stop()" [disabled]="!tracking" class="ion-margin-top" color="danger" expand="block" size="large">
    Stop
    Tracking
  </ion-button>

  <div *ngIf="tracking">
    <div>Accuracy: {{locationTracker.pos?.accuracy}}</div>
    <div>Bearing: {{locationTracker.pos?.bearing}}</div>
    <div>Latitude: {{locationTracker.pos?.latitude}}</div>
    <div>Longitude: {{locationTracker.pos?.longitude}}</div>
    <div>Speed: {{locationTracker.pos?.speed}}</div>
    <div>Time: {{locationTracker.pos?.time | date:'medium'}}</div>
  </div>

  <ion-button (click)="clear()" class="ion-margin-top" color="primary" expand="block">Clear Data</ion-button>
</ion-content>

home.page.html

With the start and stop buttons, the user manually starts and stops the location tracking. The clear button deletes all the location data stored on the server and the connected map clients. The finished application looks like this.

App Screenshot

In the TypeScript code for the HomePage, we implement the three click listeners and call methods on the LocationTracker and ServerPush services.

import {Component} from '@angular/core';
import {ServerPushService} from '../server-push.service';
import {LocationTrackerService} from '../location-tracker.service';

@Component({
  selector: 'app-home',
  templateUrl: './home.page.html',
  styleUrls: ['./home.page.scss']
})
export class HomePage {

  tracking: boolean;

  constructor(public readonly locationTracker: LocationTrackerService,
              private readonly serverPush: ServerPushService) {
    this.tracking = false;
  }

  start(): void {
    this.tracking = true;
    this.locationTracker.startTracking();
  }

  stop(): void {
    this.locationTracker.stopTracking();
    this.tracking = false;
  }

  clear(): void {
    this.serverPush.clear();
  }

}

home.page.ts

With the tracking flag, the application disables the Start button and enables the Stop button when the tracking is active and vice-versa when it's deactivated.


LocationTrackerService

The LocationTrackerService reads the current position of the device with two methods. When the user starts the tracking, the provider first asks the browser Geolocation API to get the current location. Then it installs the BackgroundGeolocation plugin, which retrieves the current location continuously while the app is in the background (or foreground).

  startTracking(): void {
    this.getForegroundLocation();
    this.startBackgroundLocation();
  }

location-tracker.service.ts

The getForegroundLocation method fetches the current location with the browser Geolocation API. We enable the option enableHighAccuracy to get the most accurate position as possible. The getCurrentPosition() method runs asynchronously and returns a Promise. In the then handler, the method sends the position to the server with the help of the ServerPushService.

  getForegroundLocation(): void {
    const geoOptions = {
      enableHighAccuracy: true,
      maximumAge: 30000,
      timeout: 10000
    };

    navigator.geolocation.getCurrentPosition(loc => {
      this.pos = {
        accuracy: loc.coords.accuracy,
        bearing: loc.coords.heading,
        latitude: loc.coords.latitude,
        longitude: loc.coords.longitude,
        speed: loc.coords.speed,
        time: loc.timestamp
      };
      this.serverPush.pushPosition(this.pos);
    }, err => this.serverPush.pushError(err.message), geoOptions);
  }

location-tracker.service.ts

The startBackgroundLocation method configures and starts the background geolocation plugin. It installs an event handler that listens for the location event. Each time the plugin reports a new location, the application sends it to the server with the pushPosition() method from the ServerPushService.

The code checks with the BackgroundGeolocation.checkStatus() method if the plugin is already running. If not it starts the plugin with BackgroundGeolocation.start().

  startBackgroundLocation(): void {
    BackgroundGeolocation.configure({
      desiredAccuracy: BackgroundGeolocation.HIGH_ACCURACY,
      stationaryRadius: 20,
      distanceFilter: 30,
      stopOnTerminate: false,
      debug: false,
      notificationTitle: 'geotracker',
      notificationText: 'Demonstrate background geolocation',
      activityType: 'AutomotiveNavigation',
      locationProvider: BackgroundGeolocation.ACTIVITY_PROVIDER,
      interval: 90000,
      fastestInterval: 60000,
      activitiesInterval: 80000
    });

    BackgroundGeolocation.on('location', (location: any) => {
      BackgroundGeolocation.startTask((taskKey: any) => {
        if (location) {
          this.pos = {
            accuracy: location.accuracy,
            bearing: location.bearing,
            latitude: location.latitude,
            longitude: location.longitude,
            speed: location.speed,
            time: location.time
          };
          this.serverPush.pushPosition(this.pos);
          this.appRef.tick();
        }
        BackgroundGeolocation.endTask(taskKey);
      });
    });

    BackgroundGeolocation.on('stationary', (stationaryLocation: any) => {
      // handle stationary locations here
    });

    BackgroundGeolocation.on('error', (error: any) => this.serverPush.pushError(error));

    BackgroundGeolocation.checkStatus((status: any) => {
      console.log('[INFO] BackgroundGeolocation service is running', status.isRunning);
      console.log('[INFO] BackgroundGeolocation services enabled', status.locationServicesEnabled);
      console.log('[INFO] BackgroundGeolocation auth status: ' + status.authorization);

      if (!status.isRunning) {
        BackgroundGeolocation.start();
      }
    });
  }

location-tracker.service.ts

The background geolocation plugin supports many configuration parameters. You find the description of all the supported options in the readme on the project site: https://github.com/mauron85/cordova-plugin-background-geolocation#readme

Don't forget that continuously retrieving the current position drains the battery of the mobile device faster.
Pay special attention to the *interval options. They specify how often the plugin wakes up and retrieves the current location.

Note: When you want to run long-running tasks in the location event as we do here for sending the position to the server, you have to run this code in a BackgroundGeolocation.startTask block. Call BackgroundGeolocation.endTask(taskKey) as soon as the task is finished.


The stopTracking() method is called when the user clicks "Stop Tracking" and stops the background geolocation plugin.

  stopTracking(): void {
    BackgroundGeolocation.stop();
  }

location-tracker.service.ts


ServerPushService

The ServerPushService is sending data to the server. Therefore we need an instance of the HTTP client that we inject into the constructor.

@Injectable({
  providedIn: 'root'
})
export class ServerPushService {

  constructor(private readonly httpClient: HttpClient) {
  }

server-push.service.ts

The pushPosition() method sends the position data to the server. In case of an error, the application tries to send the error message to the server with pushError().

  pushPosition(pos: AppPosition | null): void {
    this.httpClient.post(`${environment.serverURL}/pos`, pos)
      .subscribe({error: error => this.pushError(error)});
  }

server-push.service.ts

The pushError() method is a convenient way to send client errors to the server. This way, we can collect all the error messages in one place on the server.
It's not a perfect solution, because it is not going to work when the user agent does not have a connection to our server.

  pushError(error: string): void {
    this.httpClient.post(`${environment.serverURL}/clienterror`, error, {headers: new HttpHeaders({'Content-Type': 'text/plain'})})
      .subscribe({error: er => console.log(er)});
  }

server-push.service.ts

The clear() method is called whenever the user clicks on the clear button and wants to remove all the stored location data. This method sends a DELETE HTTP request to the /clear endpoint.

  clear(): void {
    this.httpClient.delete(`${environment.serverURL}/clear`).subscribe({error: error => this.pushError(error)});
  }

server-push.service.ts

This concludes the implementation of the Ionic/Cordova app.


Spring Boot server

I usually start the development with the Spring Initializr website (https://start.spring.io). As dependency, we only need "Web".
Download the generated project, unzip it, and import it into an IDE.

Next, we add one additional library to the pom.xml

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>

pom.xml

Shameless self-plug: I wrote this library, and it extends Spring's Server-Sent Event support with a client registry and a simple way to publish messages to the connected clients with Spring's event publishing system.

First, we need to enable the SSE eventbus with the @EnableSseEventBus annotation. You can put this on any @Configuration class. In this project, I put it on the main class.

@SpringBootApplication
@EnableSseEventBus
public class Application {

  public static final Logger logger = LoggerFactory.getLogger(Application.class);

  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

Application.java

For this project, we create one Controller and one POJO.

The Position POJO is a simple object that contains all the data the client sends to the server.

public class Position {

  private Double accuracy;

  private Double bearing;

  private double latitude;

  private double longitude;

  private Double speed;

  private long time;

Position.java

The Controller handles the incoming requests with the location data from the Ionic/Cordova application and then broadcasts these locations to all connected map clients with Server-Sent Events.

We inject all the necessary Spring beans into our Controller via constructor injection. The ApplicationEventPublisher allows the application to publish events with Spring's internal event publishing system. Jackson's ObjectMapper is used for serializing the POJO to a JSON string, and the SseEventBus bean from the sse-eventbus library manages the connected map clients in an internal registry and helps broadcasting messages to those clients.

@CrossOrigin
public class GeoController {

  private final ApplicationEventPublisher publisher;

  private final List<Position> positions;

  private final ObjectMapper objectMapper;

  private final SseEventBus eventBus;

  public GeoController(ApplicationEventPublisher publisher, ObjectMapper objectMapper,
      SseEventBus eventBus) {
    this.publisher = publisher;
    this.positions = new ArrayList<>();
    this.objectMapper = objectMapper;
    this.eventBus = eventBus;
  }

GeoController.java

The positions List holds the last 100 locations. When a new map client connects, he first fetches this collection and displays it on the map. This way, a new map client does not need to wait for the Ionic/Cordova application to send a new location until he sees something on the map.

This is what the first handler method does. It simply returns the positions collection object.

  @GetMapping("/positions")
  public List<Position> fetchPositions() {
    return this.positions;
  }

GeoController.java

The next handler handles the connection establishment requests from the EventSource object in the browser.
A requirement of the sse-eventbus library is that each client needs to send a unique id. In this project, we use the JavaScript uuid library to create this id.

The handler calls the createSseEmitter() method from the eventBus object with the client id and a list of events that the client wants to listen to.
In a more complex application, the names of the events could come from the client, but here we know that each client wants to listen to the two events pos and clear.
The createSseEmitter() method creates a new instance of the SseEmitter class from Spring's Server-Sent Events support, that we have to return from this method.
Internally the eventBus instance holds a reference to this emitter so he can broadcast messages to the client.

  public SseEmitter eventbus(@PathVariable("id") String id,
      HttpServletResponse response) {
    response.setHeader("Cache-Control", "no-store");
    return this.eventBus.createSseEmitter(id, "pos", "clear");
  }

GeoController.java

Next, we implement the clear handler. This method handles the DELETE HTTP request when the user clicks on the "Clear" button in the Ionic app. The method clears the position collection and publishes a clear event which then the SSE eventBus broadcasts to all connected map clients, so they can remove the markers from the map.

  @DeleteMapping(path = "/clear")
  @ResponseStatus(value = HttpStatus.NO_CONTENT)
  public void clear() {
    this.positions.clear();
    this.publisher.publishEvent(SseEvent.ofEvent("clear"));
  }

GeoController.java

Because this method does not return anything, we have to send the HTTP Status 204 (No Content) back to the Ionic app. Angular's HTTP client throws an exception when he receives an HTTP response with status code 200 and an empty body.

The clienterror method handles the error requests coming from the Ionic/Cordova app.
It simply logs them in the application log. This is just a simple way to collect the client errors on the server, so we don't miss any client errors.

  @PostMapping(path = "/clienterror")
  @ResponseStatus(value = HttpStatus.NO_CONTENT)
  public void handleError(@RequestBody String errorMessage) {
    Application.logger.error(errorMessage);
  }

GeoController.java

Like clear() this method has to send the status code 204 back because it returns nothing.


And finally, the pos handler that handles the requests coming from the Ionic app with the current position. Here we pass our Position POJO class as the parameter.
Spring automatically deserializes the JSON coming from the client into this class.
The handler first publishes a pos event with the new position. The map clients expect an array, so we have to wrap it in a collection before converting it into a JSON string.
After that, the handleLocation() method stores the POJO in the positions List and removes the oldest one if the collection holds more than 100 elements.

The SSE eventbus picks up the pos event and broadcasts it to all connected map clients.

  @PostMapping(path = "/pos")
  @ResponseStatus(value = HttpStatus.NO_CONTENT)
  public void handleLocation(@RequestBody Position position)
      throws JsonProcessingException {
    SseEvent event = SseEvent.of("pos",
        this.objectMapper.writeValueAsString(Collections.singleton(position)));
    this.publisher.publishEvent(event);

    this.positions.add(position);
    if (this.positions.size() > 100) {
      this.positions.remove(0);
    }
  }

GeoController.java


And this concludes the server implementation.


Google Maps Website

This website is served from the Spring Boot application, so we put the code into the src/main/resources/static folder. But this is just a static site so you could host this on any web server.

The website consists of three files. index.html, app.js, and uuid.js.

The index.html loads the UUID library, the main application JavaScript file, and the Google Maps library and adds a title and a div for the map to the document.

<!DOCTYPE html>
<html lang="en">
........
<body>
    <h3>GeoTracker</h3>
    <div id="map"></div>

    <script src="uuid.js"></script>
    <script src="app.js"></script>
    <script src="https://maps.googleapis.com/maps/api/js?key=AIzaSy.....&callback=init"></script>
</body>
</html>

src/main/resources/static/index.html

Make sure that you get your own Google Maps key. The key in this example only works when you access the map with http://localhost:8080.
I added a description to the end of this blog on how to request a Google Maps key.

The &callback=init query parameter tells the Maps library to call the init() function when the library is loaded and ready for use.

You find the init() method in the app.js file. It does three things, it configures and shows the map, fetches the positions from the server, and subscribes to the pos and clear event.

function init() {
  loadMap();
  loadPositions();
  subscribeToServer();
}

app.js

The loadMap method centers the map at some arbitrary location configures a zoom level and the type of the map. Then it connects the map object with the div element on the HTML page.

function loadMap() {
  const latLng = new google.maps.LatLng(39, 34);

  const mapOptions = {
    center: latLng,
    zoom: 3,
    mapTypeId: google.maps.MapTypeId.ROADMAP
  }

  map = new google.maps.Map(document.getElementById("map"), mapOptions);
}

app.js

The loadPositions() method sends a GET request with the Fetch API to retrieve the stored locations from the server. When the response comes back, it converts it to a JSON and passes it to the handlePositions() method.

function loadPositions() {
  fetch('positions').then(resp=>resp.json()).then(handlePositions);
}

app.js

The handlePositions() method iterates over the array of locations and calls the handlePosition() method with each position. Then it centers the map on the latitude/longitude of the last position.

function handlePositions(positions) {
  for (let position of positions) {
    handlePosition(position);
  }

  if (positions.length > 0) {
    const lastPos = positions[positions.length - 1];
    const latlng = new google.maps.LatLng(lastPos.latitude, lastPos.longitude);
    map.setCenter(latlng);
  }
}

app.js

The handlePosition() method is responsible for drawing the markers onto the map. It first creates a golden marker for the current location. This marker denotes the last position that the Ionic/Cordova app sent.
The background location plugin also sends information about the accuracy of the position. We use this information and draw a circle around the current location marker. A bigger circle means the position data is less accurate. A tiny circle denotes a very accurate position.

function handlePosition(position) {
  const latlng = new google.maps.LatLng(position.latitude, position.longitude);

  if (!currentLocationMarker) {
    currentLocationMarker = new google.maps.Marker({
      map: map,
      position: latlng,
      icon: {
        path: google.maps.SymbolPath.CIRCLE,
        scale: 7,
        fillColor: 'gold',
        fillOpacity: 1,
        strokeColor: 'white',
        strokeWeight: 3
      }
    });
    locationAccuracyCircle = new google.maps.Circle({
      fillColor: 'purple',
      fillOpacity: 0.4,
      strokeOpacity: 0,
      map: map,
      center: latlng,
      radius: position.accuracy
    });
  }
  else {
    currentLocationMarker.setPosition(latlng);
    locationAccuracyCircle.setCenter(latlng);
    locationAccuracyCircle.setRadius(position.accuracy);
  }

app.js

The method then checks if a previous position exists. If yes, it pushes the previous position into the locationMarkers array and displays them on the map with green markers. This way, we see the current location with a golden marker and all the previously visited locations with a green marker.
Like the server, we limit the number of markers to 100, so the code removes the oldest entry from the array if it contains more than 100 elements.

  if (previousPosition) {
    locationMarkers.push(new google.maps.Marker({
      icon: {
        path: google.maps.SymbolPath.CIRCLE,
        scale: 7,
        fillColor: 'green',
        fillOpacity: 1,
        strokeColor: 'white',
        strokeWeight: 3
      },
      map: map,
      position: new google.maps.LatLng(previousPosition.latitude, previousPosition.longitude)
    }));

    if (locationMarkers.length > 100) {
      const removedMarker = locationMarkers.shift();
      removedMarker.setMap(null);
    }
  }
  else {
    map.setCenter(latlng);
    if (map.getZoom() < 15) {
      map.setZoom(15);
    }
  }

app.js

Next, we want to connect all these markers with a line. For that, the code creates a Polyline object. The Polyline object manages internally a path array that we can access with the getPath() method. There we add the latitude/longitude of the positions and again limit the number of elements to 100. The Polyline object automatically draws the lines between the provided locations.

  if (!path) {
    path = new google.maps.Polyline({
      map: map,
      strokeColor: 'blue',
      strokeOpacity: 0.4
    });
  }
  const pathArray = path.getPath();
  pathArray.push(latlng);
  if (pathArray.getLength() > 100) {
    pathArray.removeAt(0);
  }

  previousPosition = position;

app.js


The result of this code looks like this after the client sent a few location points. Map Path


Next we implement subscribeToServer(), the last method called from the init() function. This method creates an EventSource object pointing to the /register URL and with the unique id generated by the uuid library. Then it registers listeners for the pos and clear event. The pos handler is called each time the map client receives a new location and calls the handlePositions() method.
The clear event triggers a call of the clear() method.

function subscribeToServer() {
  eventSource = new EventSource(`register/${uuid.v4()}`);

  eventSource.addEventListener('pos', response => {
    for (const line of response.data.split('\n')) {
      handlePositions(JSON.parse(line));
    }
  }, false);

  eventSource.addEventListener('clear', x => clear(), false);
}

app.js

The clear() method removes all the markers, circles, and paths from the map and sets the variables to null.

function clear() {
  locationMarkers.forEach(r => r.setMap(null));
  locationMarkers = [];
  previousPosition = null;
  
  if (currentLocationMarker) {
    currentLocationMarker.setMap(null);
    currentLocationMarker = null;
  }

  if (locationAccuracyCircle) {
    locationAccuracyCircle.setMap(null);
    locationAccuracyCircle = null;
  }

  if (path) {
    path.setMap(null);
    path = null;
  }
}

app.js


Testing

One last thing we need to do before we can test it. Make sure that the URL in the environment object points to an URL that the Spring Boot application is listening on

export const environment = {
  production: false,
  serverURL: 'https://afafbe43.ngrok.io'
};

environment.ts

If you install the app on a mobile device and you leave the range of your local network, you need to install the Spring Boot application on a computer that is publicly available on the Internet, or you have a VPN installed on the mobile device that routes all the traffic to your internal network.

Another solution during development is to use ngrok. ngrok is a service that allows you to expose a local server behind a NAT or a firewall. The basic service is free. For some more advanced features, you need to pay a monthly fee. You don't have to install anything, just download the executable for your platform and start it from the command line.

The Spring Boot server runs by default on port 8080, so we start ngrok with this command.

ngrok http 8080

ngrok then gives you an HTTP and https URL that is publicly accessible and redirect the traffic to your local computer on port 8080. Now assign the ngrok https URL to the serverURL instance variable.

  serverURL: 'https://afafbe43.ngrok.io'

environment.prod.ts

You can now build and install the Ionic app on a device. I use an Android phone, so I can simply connect the device with a USB cable to my computer and run this Ionic CLI command.

ionic cordova run android --prod

Start the Spring Boot server from either inside the IDE by launching the main class or on the command line with mvn spring-boot:run. It does not matter if you start ngrok first or after you started Spring Boot. Then open a browser and go to the URL: http://localhost:8080/
The browser should display the world map.

Now open the app on the mobile device and click on the Start Tracking button. Make sure that you enable location service on your device. As soon as you tap the start button and everything works without an error, the map in the browser should show you a golden marker with the current location. Thanks to the background geolocation plugin, you can put the app into the background and use the device for other things.

Now you can give it a test drive (or run, or walk). When you come back, you should see the route you traveled on the map.

You could also give the ngrok URL to a colleague or a friend, and he can watch your location. Before this works, you need to authorize the Google Maps key for this particular ngrok URL when the Google Maps key is restricted with HTTP referrers (see description at the end of this blog post).

You find the complete source code for this application on GitHub:
https://github.com/ralscha/blog/tree/master/geotracker


The application we developed here leaves room for many improvements. Especially the support for multiple devices. This application can currently only handle and display the location of one device. To support multiple devices, you could assign a unique id to each client and send this id together with the location data to the server. The map could then show the locations with different colored markers, or you could add a possibility to choose the device/person you want to track.


Google Maps API Key

How you get a Google Maps key.

First, create a Google account if you don't already have one. https://accounts.google.com/SignUp?hl=en

Open the Google Developer Console:
https://console.developers.google.com

Select the Dashboard menu and click "Enable Apis and Services".
Dashboard

Search for the Google Maps JavaScript API and select it.
API Search

Click "Enable"
Enable

Select the Credentials menu, click "Create credentials" and select "API key".
API Key
Give the key a descriptive name and select a key restriction. I usually choose HTTP referrers. When you choose HTTP referrers restriction, you have to list all the domains from where you want to access the website from under "Accept requests from these HTTP referrers".

Click Save

Copy the key and insert it into the code.

    <script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyAZjJ216B4aJGdXTwXNevmXesob9RUSlPc&callback=init"></script>

index.html