How To Make an Android Run Tracking App
Learn how to make an Android run tracking app to show a user’s position on a map, along with their path. By Roberto Orgiu.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How To Make an Android Run Tracking App
30 mins
- Getting Started
- Location, Location, Location
- Getting the User’s Location
- Asking Permission to Get Your User’s Location
- Testing for the First Time
- Showing User Movement
- Presenting Location Data
- Recognizing the User’s Activity
- Asking for Another Permission
- Adding More Glue
- Drawing the UI, At Last!
- Where to Go From Here?
Showing User Movement
Now, you can track your user’s movement and mark their location on the map, but you really want to draw the path they moved across. To do so, you need to change the LocationProvider
class.
Open LocationsProvider.kt and right below the locations
list, add the distance
:
private var distance = 0
Now your code updates the distance every time the software records a new position for the user. But, you need additional LiveData
to emit the distance the user moves.
Below the declaration of liveLocation
, add:
val liveLocations = MutableLiveData<List<LatLng>>()
val liveDistance = MutableLiveData<Int>()
Since the position update API needs a callback to work, you need to create the callback before you can ask for the location. Create this as a nested class inside LocationProvider.kt:
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
//1
val currentLocation = result.lastLocation
val latLng = LatLng(currentLocation.latitude, currentLocation.longitude)
//2
val lastLocation = locations.lastOrNull()
//3
if (lastLocation != null) {
distance +=
SphericalUtil.computeDistanceBetween(lastLocation, latLng).roundToInt()
liveDistance.value = distance
}
//4
locations.add(latLng)
liveLocations.value = locations
}
}
Here’s a code breakdown:
- You get the currently recorded location, and tranform it into a LatLng that the map can plot easily.
- Then, you check if there are other locations. Since you need it to calculate the distance between the user’s last point and current point, you need the last location before the current one.
- If the current location is not the first one, you use the
SphericalUtil
functions to compute the distance between the two points and add it to the distance you emit through itsLiveData
. - In the last instance, you add the current location to the list of recorded positions and emit it.
Now, the callback is ready, but you need to register it! Paste trackUser
in LocationProvider
:
fun trackUser() {
//1
val locationRequest = LocationRequest.create()
locationRequest.priority = LocationRequest.PRIORITY_HIGH_ACCURACY
locationRequest.interval = 5000
//2
client.requestLocationUpdates(locationRequest, locationCallback,
Looper.getMainLooper())
}
In the first part of the snippet, you create a request. This request should be at high accuracy since you want to track the user moving at a relatively slow speed. For the same reason, you set the recording’s interval to five seconds, which is more than enough for this kind of movement.
Finally, remove this callback when the user stops the tracking:
fun stopTracking() {
client.removeLocationUpdates(locationCallback)
locations.clear()
distance = 0
}
This method clears all the data you’ve gathered so far, leaving the class ready to track again immediately!
You’re almost ready to draw that path. Time to do it!
Presenting Location Data
Sharing three LiveData
s in the UI, plus one for the step count, is a bit cumbersome. Instead, create something that can represent your screen’s state.
Inside your app’s main package, create a file named MVP.kt.
Inside your new file, paste this data class
:
data class Ui(
val formattedPace: String,
val formattedDistance: String,
val currentLocation: LatLng?,
val userPath: List<LatLng>
) {
companion object {
val EMPTY = Ui(
formattedPace = "",
formattedDistance = "",
currentLocation = null,
userPath = emptyList()
)
}
}
This simple class contains all the information you need to render your UI correctly: The distance the user walks, their current location and the list of locations the app recorded. Great!
Now, you need something to get all the data from the sensors, glue them together and let the UI know that something new is available. In the same file, create a new class like this:
class MapPresenter(private val activity: AppCompatActivity) {
}
You’re closer to the win! Declare a field in the presenter that will emit the new data every time there’s an update:
val ui = MutableLiveData(Ui.EMPTY)
Next, create the dependencies you need, like the LocationProvider
and the PermissionManager
:
private val locationProvider = LocationProvider(activity)
private val permissionsManager = PermissionsManager(activity, locationProvider)
Now, you need to create a function that will glue all the data together inside the Ui data class
. Add this function to the MapPresenter:
fun onViewCreated() {
}
At this point, you need to get the three LiveData
s from the LocationProvider
, attaching an observer to each of them. For this task, you’ll need the Activity
you have in the constructor, and to use the copy
function of the data class
.
Inside onViewCreated
, paste:
locationProvider.liveLocations.observe(activity) { locations ->
val current = ui.value
ui.value = current?.copy(userPath = locations)
}
locationProvider.liveLocation.observe(activity) { currentLocation ->
val current = ui.value
ui.value = current?.copy(currentLocation = currentLocation)
}
locationProvider.liveDistance.observe(activity) { distance ->
val current = ui.value
val formattedDistance = activity.getString(R.string.distance_value, distance)
ui.value = current?.copy(formattedDistance = formattedDistance)
}
For each LiveData
you listen to, you get its value, optionally format it to something more readable and update the ui LiveData
to emit the new data.
Now your IDE will complain that R.string.distance_value
isn’t available: Time to fix that!
Open strings.xml and paste this line below the other string
declarations:
<string name="distance_value">%d meters</string>
This label will take a number as parameter and place it where the %d
is, so that if you pass 5
, you get back a string saying 5 meters.
At this point, you want to add the logic to ask permission from the presenter by adding onMapLoaded
:
fun onMapLoaded() {
permissionsManager.requestUserLocation()
}
This method will run as soon as the Google Maps container is ready, just like earlier!
Now, add two methods to handle the user pressing the start and stop button in the UI. For the moment they only link to the LocationProvider
:
fun startTracking() {
locationProvider.trackUser()
}
fun stopTracking() {
locationProvider.stopTracking()
}
This way, you only interact with one object, which is much easier and cleaner than dealing with many objects at once.
It’s time to build! Run your app and press the start button. Move around, and your app will track you and display your updated position.
You’re getting closer!
Recognizing the User’s Activity
So far, you still lack the pace, or the number of steps the user takes, before you update your UI fully. This requires you to recognize what the user is doing.
Create another Kotlin file and name it StepCounter.kt. Inside this file, you’ll check the user’s activity and how many steps they take.
First, create a class that extends SensorEventListener
:
class StepCounter(private val activity: AppCompatActivity) : SensorEventListener {
}
At the top of this class, you need to declare the LiveData
containing the steps, an instance of the SensorManager
that provides access to the specific sensors, and an instance of the as fields. Moreover, you’ll declare another variable that contains the initial steps because the number of steps resets at every boot. So at any given moment, you’ll get the number of steps the user walked from the last time they rebooted the phone.
With this variable, you’ll know exactly when you start observing, and you’ll give a much more precise measure of the steps the user took during the activity.
Insert the following code at the top of StepCounter
:
val liveSteps = MutableLiveData<Int>()
private val sensorManager by lazy {
activity.getSystemService(SENSOR_SERVICE) as SensorManager
}
private val stepCounterSensor: Sensor? by lazy {
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
}
private var initialSteps = -1
Since you implemented the SensorEventListener
interface, you also need to implement a couple of methods. The first is the one you really care about. In the onSensorChanged
function, you’ll get the updates you need.
You only need the second method, onAccuracyChanged
, when you perform logic based on the accuracy. But it’s not your case, so it can simply return Unit
:
override fun onSensorChanged(event: SensorEvent) {
event.values.firstOrNull()?.toInt()?.let { newSteps ->
//1
if (initialSteps == -1) {
initialSteps = newSteps
}
//2
val currentSteps = newSteps - initialSteps
//3
liveSteps.value = currentSteps
}
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
Pause a moment and analyze the first method. As you already know, the number of steps you receive is the one calculated from the device boot. So, to avoid confusion, you keep track of the steps by using the initialSteps
variable.
- If this variable is still negative, it means you didn’t record the start yet, so you set it equal to the sensor data.
- Then, you calculate the delta between the new data from the sensor and the initial steps. The first time you get data, this difference be zero, as expected.
- In this last line, you set the
LiveData
to thecurrentSteps
you calculated, so all the listeners will react to its changes.
Now, you only need two more methods: One adds this same class as listener to the SensorManager
, while the other removes the listener:
fun setupStepCounter() {
if (stepCounterSensor != null) {
sensorManager.registerListener(this, stepCounterSensor, SENSOR_DELAY_FASTEST)
}
}
fun unloadStepCounter() {
if (stepCounterSensor != null) {
sensorManager.unregisterListener(this)
}
}
Build your app to make sure that everything is in place. You might find that running your app causes it to crash: This is expected. After all, you still need some permissions to be ready!