Developing a self hosted location tracker

Published: November 07, 2017  •  Updated: February 16, 2018  •  ionic, java, spring

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://developers.google.com/maps/pricing-and-plans/


Ionic/Cordova app

The Ionic application is based on the blank starter template. We add the two Cordova plugins and the Ionic native adapters. And we add the Cordova platform so we can install it on a device. 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 plugin add cordova-plugin-mauron85-background-geolocation
npm install @ionic-native/background-geolocation
ionic cordova plugin add cordova-plugin-geolocation --variable GEOLOCATION_USAGE_DESCRIPTION="To locate you"
npm install @ionic-native/geolocation
ionic cordova platform add android

Then we create two providers.

ionic g provider LocationTracker
ionic g provider ServerPush

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

First we have to add the two Ionic Native providers Geolocation and BackgroundGeolocation to the providers section in the src/app/app.module.ts file

providers: [
 Geolocation,
 BackgroundGeolocation,
 StatusBar,
 SplashScreen,
 {provide: ErrorHandler, useClass: IonicErrorHandler},
 LocationTrackerProvider,
 ServerPushProvider
]

src/app/app.module.ts

Because we need the http client to send data to the server, add the HttpClientModule to the imports section.

imports: [
 BrowserModule,
 HttpClientModule,
 IonicModule.forRoot(MyApp)
],

src/app/app.module.ts

The GUI for the app is very simplistic. It consists only of three buttons. Open home.html and insert this code

<ion-header>
 <ion-navbar>
   <ion-title>
     GeoLocation
   </ion-title>
 </ion-navbar>
</ion-header>

<ion-content padding>
 <button ion-button color="primary" (click)="start()" [disabled]="tracking" block>Start Tracking</button>
 <button ion-button color="primary" (click)="stop()" [disabled]="!tracking" block>Stop Tracking</button>
 <button ion-button color="primary" (click)="clear()" block>Clear Data</button>
</ion-content>

src/pages/home/home.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 on the connected map clients. The finished application looks like this.

App Screenshot

The TypeScript code for the HomePage, where we implement the three click listeners.

export class HomePage {
 tracking: boolean;
 constructor(private locationTracker: LocationTrackerProvider,
             private serverPush: ServerPushProvider) {
   this.tracking = false;
 }

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

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

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

src/pages/home/home.ts

The two providers are injected into the HomePage and the three click handlers call the methods from these providers. With the tracking flag the application disables the Start button and enables the Stop button when the tracking is active and vice-versa when the tracking is deactivated.

The LocationTrackerProvider reads the current position of the device with two methods. When the user starts the tracking the provider first asks the foreground Geolocation plugin 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).

@Injectable()
export class LocationTrackerProvider {

In the constructor we inject the two location plugin objects from Ionic Native. They give the application access to the foreground and background Geolocation plugins.

 constructor(private serverPush: ServerPushProvider,
             private geolocation: Geolocation,
             private backgroundGeolocation: BackgroundGeolocation) {
 }

The startTracking() method is called when the user clicks the "Start Tracking" button. It first calls the foreground location plugin to get the current location and then installs and starts the background plugin.

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

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

 stopTracking(): void {
   this.backgroundGeolocation.stop();
 }

The next method fetches the current location with the foreground geolocation plugin. 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 ServerPushProvider.

 getForegroundLocation(): void {
   this.geolocation.getCurrentPosition({
     enableHighAccuracy: true,
     timeout: 10000
   }).then(loc => {
     this.serverPush.pushPosition({
       accuracy: loc.coords.accuracy,
       bearing: loc.coords.heading,
       latitude: loc.coords.latitude,
       longitude: loc.coords.longitude,
       speed: loc.coords.speed,
       time: loc.timestamp
     });
   });
 }

Next we implement the code for the background geolocation plugin.

The backgroundGeolocation.configure() method returns an Observable that the application can subscribe to. Each time the plugin reports a new location the application sends it to the server with the pushPosition() method from the ServerPushProvider.

 startBackgroundLocation(): void {
   const backgroundOptions: BackgroundGeolocationConfig = {
     desiredAccuracy: 10,
     stationaryRadius: 20,
     distanceFilter: 30,
     stopOnTerminate: false,
     debug: false,
     notificationTitle: 'geotracker',
     notificationText: 'Demonstrate background geolocation',
     activityType: 'AutomotiveNavigation',
     locationProvider: this.backgroundGeolocation.LocationProvider.ANDROID_ACTIVITY_PROVIDER,
     interval: 90000,
     fastestInterval: 60000,
     activitiesInterval: 80000
   };

   this.backgroundGeolocation.configure(backgroundOptions)
     .catch(error => {
       this.serverPush.pushError(error);
       return new EmptyObservable();
     })
     .subscribe((location: BackgroundGeolocationResponse) => {
       if (location) {
         this.serverPush.pushPosition({
           accuracy: location.accuracy,
           bearing: location.bearing,
           latitude: location.latitude,
           longitude: location.longitude,
           speed: location.speed,
           time: location.time
         });
       }
       this.backgroundGeolocation.finish();
     });

   this.backgroundGeolocation.start();
 }
}

src/providers/location-tracker/location-tracker.ts

The background geolocation plugin supports a lot of parameters. You find the description of all these options in the readme on the project site: https://github.com/mauron85/cordova-plugin-background-geolocation

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 should wake up and retrieve the current location.

Note: Your code has to call the backgroundGeolocation.finish() method each time the code finished processing the current location. This does not stop the background plugin (stop() does) it simply tells the operating system that you are finished with processing and the device can go back to sleep. This is especially important on iOS. According to the documentation this operating system will crash the app when the background processing takes too long.

The last class we have to implement is the ServerPushProvider that sends the data to server.

@Injectable()
export class ServerPushProvider {
 private serverURL: string = 'https://153378fe.ngrok.io';

The ServerPushProvider is sending data to the server, therefore we need an instance of the http client, that we inject into the constructor.

 constructor(private readonly httpClient: HttpClient) {
 }

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

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

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 especially when the error is in the code that sends the data to the server.

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

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 and the server deletes all the stored data.

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

src/providers/server-push/server-push.ts

This concludes the implementation of the Ionic/Cordova app.


Spring Boot server

The development starts as usual with the Spring Initializr (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

<dependency>
  <groupId>ch.rasc</groupId>
  <artifactId>sse-eventbus</artifactId>
  <version>1.1.5</version>
</dependency>

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 {

src/main/java/ch/rasc/geotracker/Application.java

For this project we create one Controller and one Pojo class

The Position pojo is a simple wrapper of all the location data the client collects on the device and sends it to the server.

public class Position {
  private Double accuracy;
  private Double bearing;
  private double latitude;
  private double longitude;
  private Double speed;
  private long time;
     //get and set methods
}

src/main/java/ch/rasc/geotracker/Position.java

The Controller handles the incoming requests containing 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 sends the messages to the clients.

@RestController
@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;
  }

The positions instance variable 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 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. Spring automatically converts this into a JSON, because of the @RestController annotation on the class.

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

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 directly on the client.

The handler calls the createSseEmitter() method from the eventBus object with the client id and a list of events which the client wants to listen on. 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 on 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.

  @GetMapping("/register/{id}")
  public SseEmitter eventbus(@PathVariable("id") String id) {
    return this.eventBus.createSseEmitter(id, "pos", "clear");
  }

Next we implement the clear handler. This handles the DELETE HTTP request when the user clicks on the "Clear" button in the client app. The method clears the position collection and publishes a clear event which 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"));
  }

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 a http response with status code 200 and an empty body.

The clienterror 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);
  }

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 with the current position. Here we use our Position pojo class as parameter. Spring automatically deserializes the JSON coming from the client into this class. First the handler 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 collection 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);
    }
  }

src/main/java/ch/rasc/geotracker/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 JavaScript files 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 connect with http://localhost:8080. I've added a description to the end of this blog post that describes the process of requesting 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 stored positions from the server and subscribes to the pos and clear event.

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

The loadMap 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);
}

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);
}

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);
  }
}

The handlePosition() 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 very small 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);
  }

The code 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 previous 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. The else clause is executed the first time when there was no previous position and simply centers the map on the current position.

  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);
    }
  }

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 position 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;
}

The result of all 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);
}

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;
  }
}

src/main/resources/static/app.js


Testing

One last thing we need to do before we can test it. Make sure that the URL in the ServerPushProvider class points to an URL that reaches the Spring Boot application.

private serverURL: string = '....';

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 you start the ngrok with this command.

ngrok http 8080

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

private serverURL: string = 'https://fd8af955.ngrok.io';

src/providers/server-push/server-push.ts

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

ionic cordova run android --prod

Start the Spring Boot server from either inside the IDE with starting 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 travelled 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 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:

The application we developed here leaves room for a lot of 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 coloured markers or you could add a possibility to choose the device/person you want to track.


Google Maps API Key

Here the description how you get a Google Maps key.

First create a Google account if you don't have one already.
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.