Developing a self hosted location tracker

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

The system consists of these three applications:

An Ionic 4/Cordova app that periodically sends the current location to the server. The application retrieves the location data with the browser Geolocaton API and the Background geolocation Cordova plugin and sends it over HTTP to a Spring Boot application.

A Spring Boot application that receives the position data and broadcasts it to connected clients with Server-Sent Events.

A simple web application that connects to the Spring Boot application and displays the locations on a Google Maps. We serve the assets for this page directly from Spring Boot. But because it's a static site you could host it anywhere.

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:

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.

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.

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.

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

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).

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.

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().

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, like 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.

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 will not work when the user agent does not have a connection to our server.

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.

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.

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.

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 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.

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.

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 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.

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 use our Position POJO class as 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.

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.

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.

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 very small circle denotes a very accurate position.

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 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.

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.

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

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.

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 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.

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 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 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 Google Maps key is restricted with HTTP referrers (see description at the end of this blog post).

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.

Select the Credentials menu, click "Create credentials" and select "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".