7

How To Make an Android Run Tracking App [FREE]

 2 years ago
source link: https://www.raywenderlich.com/28767779-how-to-make-an-android-run-tracking-app
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Home Android & Kotlin Tutorials

How To Make an Android Run Tracking App

Learn how to make an android run tracking app to show user position on a map, together with their path.

By Roberto Orgiu Feb 21 2022 · Article (30 mins) · Intermediate

Version

In this tutorial, you’ll learn how to make an Android run tracking app that shows the user’s position on a map, together with their path. This approach is practical when you’re trying to build a tracker for sport sessions, like running or biking. Using the Google Maps API, you can create a tracker with this ability.

Oh, wait… that’s what you’re going to do now!

Along the way, you’ll learn how to:

  • Use the Google Maps API inside your Android project.
  • Get the user’s last recorded position.
  • Get updates on the user’s movements.
  • Recognize how the user is moving.
  • Plot the user’s path on the map.

Before you start, check out this article on how to use maps on Android, or this article about Activity Recognition. It’s always nice to have some prior knowledge of the topic!

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial. Once you load the project into your IDE, head to the Google Cloud Console and log in with your Google account. On the Credentials page, select a project or create a new one.

Beware, it could take a few minutes!

Select the dropdown at the top for the projects, then create or a select a project.

At the top of the page, click Create Credentials and select API Key. Make sure to copy the API Key because you’ll need it soon. If you get lost in the process, check the official documentation here.

Also, make sure to enable the Maps SDK for Android under the API Library.

Now, back in your IDE, select the two google_maps_api files.

Using the project view, the files are visible in the values folder of res.

The first is for the debug variant and other is for the release. Then paste your API Key where you see this code:

<resources>
  <string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">YOUR_KEY_HERE</string>
</resources>

You’re ready to build your project for the first time! Click the run button and you’ll see this UI:

android run tracking app

There are three data fields at the top of the screen: average pace, elapsed time and distance the user moved in the session. These values change when the user starts to move.

Below is your map. If you don’t see it right away, make sure you correctly pasted your API Key in the previous step and enabled the Maps SDK for Android.

Last but not least, there’s the start and stop button that you’ll use to begin and end tracking the user.

Now, you’re ready to rock!

Location, Location, Location

Before you perform any query related to locations, you need to get your dependencies right.

In the app folder, open build.gradle for the app module. Then paste these lines inside the dependencies lambda:

implementation 'com.google.android.gms:play-services-maps:17.0.1'
implementation 'com.google.android.gms:play-services-location:18.0.0'
implementation 'com.google.maps.android:android-maps-utils:2.2.3'
implementation 'com.google.maps.android:maps-utils-ktx:3.2.0'

Now, click Sync project and you’re ready to rock!

You need to get the user’s location. This step is composed of two actions that display the user’s position on a map.

First, you’ll get the user’s coordinates, and then you’ll build the logic to ask for the location permission. Once the user grants this permission, you’ll display their position on the map.

Getting the User’s Location

In the main package, create a new class called LocationProvider and paste this code inside its file:

class LocationProvider(private val activity: AppCompatActivity) {

  //1
  private val client 
    by lazy { LocationServices.getFusedLocationProviderClient(activity) }

  //2
  private val locations = mutableListOf<LatLng>()

  //3
  val liveLocation = MutableLiveData<LatLng>()

  //4
  fun getUserLocation() {
    client.lastLocation.addOnSuccessListener { location ->
      val latLng = LatLng(location.latitude, location.longitude)
      locations.add(latLng)
      liveLocation.value = latLng
    }
  }
}

Here’s a code breakdown:

  1. First, you get the object that you need to get the user’s location. You need the Activity to build it, but since you’ll only invoke it after you ensure your app has the permission, keep it lazy, so that the LocationProvider only creates it when needed.
  2. This line is the list of locations that you’ll need later. At the moment, it only contains the user’s initial position, but that’ll change soon.
  3. Here’s the LiveData that contains the device’s location. You’ll listen to this data in a few minutes!
  4. Finally, here’s your request for the user’s position. You take the client you created lazily and add a listener to it. Whenever it receives a new location, it’ll transform it to a LatLng, which contains the location’s latitude and longitude, add it to the list of the locations and emit it through the LiveData.

Now, you’ll see an IDE warning you that your code isn’t asking for the correct permission. It’s right, for the moment.

So, add this annotation to the top of this class:

@SuppressLint("MissingPermission")

Here, you tell the IDE not to warn you anymore for this class. You’re aware that you don’t have the permission, but you’re about to fix it.

Asking Permission to Get Your User’s Location

Create another class, and call it PermissionManager. Inside it, paste:

class PermissionsManager(
    activity: AppCompatActivity,
    private val locationProvider: LocationProvider) {

  //1 
  private val locationPermissionProvider = activity.registerForActivityResult(
      ActivityResultContracts.RequestPermission()) { granted ->
    if (granted) {
      locationProvider.getUserLocation()
    }
  }

  //2
  fun requestUserLocation() {
    locationPermissionProvider.launch(Manifest.permission.ACCESS_FINE_LOCATION)
  }
}

Here’s a code breakdown:

  1. First, you register a callback on the Activity when the user grants permission. This callback runs and provides a response as soon as the user presses a button in the permission dialog. If the response is positive, you request the device location with the same class you created in the previous step.
  2. Then, you ask for permission. Whenever your app is ready to display the location, you invoke this function, the system will ask for permission and, as soon as the user taps anything, your callback runs.

With that in place, it’s time to test the app!

Testing for the First Time

Now, head back to MapsActivity.kt, and add these lines at the top. Don’t worry about making them too fancy. You’ll remove them soon since you only need them to test the code you just wrote:

private val locationProvider = LocationProvider(this)
private val permissionManager = PermissionsManager(this, locationProvider)

Now, scroll down to the onMapReady method, delete all of its content and replace with:

override fun onMapReady(googleMap: GoogleMap) {
  map = googleMap

  //1
  locationProvider.liveLocation.observe(this) { latLng ->
    map.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, 14f))
  }
    
  //2
  permissionManager.requestUserLocation()

  map.uiSettings.isZoomControlsEnabled = true
}

Here’s a code breakdown:

  1. First, you observe the LiveData you created. Once the LocationProvider gets a new location, it emits it, and you move the camera accordingly.
  2. Next, you use the PermissionManager to ask for the needed permission.

When the map is ready, you get the user’s location. Build and run the app. You’ll see your position appear on your device!

android run tracking app with location

If you’re on an emulator, move your location around in the options or even import a GPX path file to see the app update the location automatically!

android run tracking app emulator

Now that you have the user’s location take it a step further by showing their movement.

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:

  1. You get the currently recorded location, and tranform it into a LatLng that the map can plot easily.
  2. 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.
  3. 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 its LiveData.
  4. 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 LiveDatas 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.

Note: For this tutorial, you’ll use a slimmed-down architecture that better fits the example and makes it easier to deal with this topic.

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

android run tracking app with location

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.

  1. If this variable is still negative, it means you didn’t record the start yet, so you set it equal to the sensor data.
  2. 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.
  3. In this last line, you set the LiveData to the currentSteps 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!

Asking for Another Permission

Starting with Android Q, recognizing a user’s activity requires a new permission. So, you need to add more logic inside the PermissionManager.

First, change the constructor of the PermissionManager so that it accepts an instance of the StepCounter:

class PermissionsManager(activity: AppCompatActivity,
    private val locationProvider: LocationProvider,
    private val stepCounter: StepCounter)

Then, declare the callback that runs when the user grants the permission on Android Q and newer. This will set up the step counter:

private val activityRecognitionPermissionProvider = 
  activity.registerForActivityResult(
      ActivityResultContracts.RequestPermission()
  ) { granted ->
    if (granted) {
      stepCounter.setupStepCounter()
    }
}

Finally, create a method that asks for the permission on Android Q and newer, or simply setup the step counter on older versions of the operating system:

fun requestActivityRecognition() {
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    activityRecognitionPermissionProvider.launch(Manifest.permission.ACTIVITY_RECOGNITION)
  } else {
    stepCounter.setupStepCounter()
  }
}

Adding More Glue

Now, head back to MapPresenter.kt and add a new instance of the StepCounter close to the declaration of the LocationProvider:

private val stepCounter = StepCounter(activity)

You also need to pass this new stepCounter to the newly updated PermissionsManager:

private val permissionsManager = PermissionsManager(activity, locationProvider, stepCounter)

Now, scroll to onViewCreated and add a listener to the LiveData, so that the data from the StepCounter can flow with the others you previously listened to:

stepCounter.liveSteps.observe(activity) { steps ->
  val current = ui.value
  ui.value = current?.copy(formattedPace = "$steps")
}

Next, add the permission request for the number of steps in startTracking. Since the user can remove permissions at any moment, every time they track their activities, you’ll need to ask for the permission again.

Don’t worry. If you have the permission already, the callback will run automatically:

permissionsManager.requestActivityRecognition()

Finally, unload the StepCounter when the user stops recording their activity, in onStopTracking:

stepCounter.unloadStepCounter()

The only thing left to take care of is the UI. Time to do it!

Drawing the UI, At Last!

Open MapsActivity.kt. You don’t need any reference to the PermissionManager nor to the LocationProvider, so, delete them. In their place, add a reference to the MapPresenter:

private val presenter = MapPresenter(this)

Once you do this, in your activity’s onCreate, use the binding to reach out for the start button and add the following logic:

binding.btnStartStop.setOnClickListener {
  if (binding.btnStartStop.text == getString(R.string.start_label)) {
    //1
    startTracking()
    binding.btnStartStop.setText(R.string.stop_label)
  } else {
    //2
    stopTracking()
    binding.btnStartStop.setText(R.string.start_label)
  }
}

In this snippet, first, you check if the button’s label is START. If it is, you start the tracking and change the label to STOP. Otherwise, you assume the label is STOP already, stop the tracking and change the label back to START.

The IDE is complaining, isn’t it? You’ll deal with it in a second. But first, add the presenter callback right below the bindings for the button:

presenter.onViewCreated()

This method tells the presenter that your UI is ready and to attach all the listeners to the LiveDatas.

Next, you’ll change the UI a little so that it better fits the code you’re about to write.

Open activity_maps.xml and replace the include tag with:

<include
      android:id="@+id/container"
      layout="@layout/layout_indicators" />

Here, you add a new container id that you’ll use later to access the fields in the layout. You need one more step.

Switch to layout_indicators.xml and find the TextView with id @+id/textTime. Change it’s type to a Chronometer, so that it looks like this:

<Chronometer
      android:id="@+id/txtTime"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_marginStart="8dp"
      android:fontFamily="monospace"
      tools:text="45 minutes"
      app:layout_constraintBottom_toBottomOf="@+id/txtTimeLabel"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintHorizontal_bias="1.0"
      app:layout_constraintStart_toEndOf="@+id/txtTimeLabel"
      app:layout_constraintTop_toTopOf="@+id/txtTimeLabel" />

Here, you change the TextView so that it tracks the elapsed time in a much easier way and displays it in the underlying TextView.

Back to the IDE warnings. To make it happy, you need to add some code. Paste this snippet in MapsActivity as class methods:

private fun startTracking() {
  //1
  binding.container.txtPace.text = ""
  binding.container.txtDistance.text = ""
  //2
  binding.container.txtTime.base = SystemClock.elapsedRealtime()
  binding.container.txtTime.start()
  //3
  map.clear()

  //4
  presenter.startTracking()
}

private fun stopTracking() {
  presenter.stopTracking()
  binding.container.txtTime.stop()
}

A few good things happen here:

  1. First, you reset the pace and distance fields since the user wants to record a new activity.
  2. Second, you set the time label to the current time and restart it. You do this because it only starts counting from the last moment recorded, so if you don’t reset it, you’ll have some weird data.
  3. Third, you clear the map. You don’t want to have old markers because you only care about the new ones coming in any minute.
  4. Last but not least, you ask the presenter to start tracking, and all the data will flow!

When you stop tracking, you stop all the presenter’s functions and also stop the time label. You don’t need to reset anything yet. This way, you also have a nice recap frame for your users.

You’re about to make the IDE unhappy once more! Right below these two methods, paste:

@SuppressLint("MissingPermission")
private fun updateUi(ui: Ui) {
  //1
  if (ui.currentLocation != null && ui.currentLocation != map.cameraPosition.target) {
    map.isMyLocationEnabled = true
    map.animateCamera(CameraUpdateFactory.newLatLngZoom(ui.currentLocation, 14f))
  }
  //2
  binding.container.txtDistance.text = ui.formattedDistance
  binding.container.txtPace.text = ui.formattedPace
  //3
  drawRoute(ui.userPath)
}

Here’s what you did:

  1. You check that the new position you get is not null and that it’s different from the position on the map. If you skip this step, you’ll experience crashes since the location could be null or weird flickers as you move the camera in and out from the same position. If the check passes, you move the camera to the new location, as you did earlier.
  2. Next, you bind the formatted data to the bindings.
  3. Finally, you draw the user’s path!

Scroll a little bit, and paste this code as another function of the MapsActivity:

private fun drawRoute(locations: List<LatLng>) {
  val polylineOptions = PolylineOptions()

  map.clear()

  val points = polylineOptions.points
  points.addAll(locations)

  map.addPolyline(polylineOptions)
}

This function is all you need to draw a line with several points on the UI.

  1. First, you create a PolylineOptions, an object that contains the points to draw on the map.
  2. Then you clear everything on the map itself.
  3. Finally, you add all the new locations and plot the line on the map.

Done!

Well, not quite. There’s one more step to take.

Are you ready? Great!

Scroll to the onMapReady method and change so that it looks like this:

override fun onMapReady(googleMap: GoogleMap) {
  map = googleMap

  //1
  presenter.ui.observe(this) { ui ->
    updateUi(ui)
  }

  //2
  presenter.onMapLoaded()
  map.uiSettings.isZoomControlsEnabled = true
}

First, you observe the data from the presenter so that you change the UI accordingly every time there’s an update. Second, you tell the presenter that the map loaded, so it can ask for the location permission if the user removed it.

Now, you really did it. It’s time to build your app and go out for a walk. You have to test it, don’t you?

Start your app and walk around. It’ll track you and display some data, more or less like this:

final-screenshot-160x320.png

Where to Go From Here?

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

In this tutorial, you learned how to deal with maps, location and several technologies involved with user movement. But what if you want to store the sessions somewhere?

Check out this video about how to implement a Room database in your app and store the data on the device.

Moreover, check out this video related to dependency injection on Android, as this techniques make it so much easier to keep a cleaner architecture.

If you have any questions or comments, please join the forums below!

raywenderlich.com Weekly

The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.

Get a weekly digest of our tutorials and courses, and receive a free in-depth email course as a bonus!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK