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:
-
An Ionic/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:
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>
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.
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();
}
}
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();
}
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);
}
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();
}
});
}
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();
}
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) {
}
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)});
}
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)});
}
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)});
}
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>
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);
}
}
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;
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;
}
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;
}
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");
}
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"));
}
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);
}
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);
}
}
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();
}
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);
}
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()
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);
}
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);
}
}
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;
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.
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;
}
}
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'
};
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'
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".
Search for the Google Maps JavaScript API and select it.
Click "Enable"
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".
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>