Wrapping It Up

Leejaywaggoner
5 min readMar 10, 2024

--

Photo by Helena Lopes on Unsplash

My mileage tracking app now detects when the user is driving — whether the app is running, or not — and launches a foreground/background service that periodically get’s their location during the drive. The only thing left is to calculate the miles driven and store each drive. I created a DriveDistance class to encapsulate the distance calculations, a Room database to store the data, and a DriveRepo class to tie it all together.

The Room database came together easily. I just followed the docs and I was fine — except for a couple things that I’ll mention later.

The three important methods in the DriveRepo class that are called from the MileageService foreground/background service are as follows:

suspend fun startTrackingDrive(startLocation: Location?) 
= withContext(Dispatchers.IO) {
//create a new drive and store it in the database
userId = dataStoreWrapper.getUserId("")
val dateTime = OffsetDateTime.now(ZoneOffset.systemDefault()).asISO8601String()
currentDriveId = drivesDao.insertDrive(
DBDrive(
userId = userId,
dateTimeCreated = dateTime,
)
)

//if the start location is valid, save it as a new segment
startLocation?.let { loc ->
newSegment(loc)
}
}

This method is called when the device detects that a drive has started. It fills out the details currently available for the drive, userId and dateTimeCreated, and inserts a new drive entry into the drives database table. This is so I can get the currentDriveId, which I’m using in the stopTrackingDrive() call and the newSegment() call, where I’m storing all of the drive segments. Why am I storing all of the drive segments? I don’t know. Because I can? 😄

suspend fun newSegment(location: Location) = withContext(Dispatchers.IO) {
//calculate a running total of the current distance driven
driveDistance.calculateCurrentDistance(location)

//save the segment
val dateTime = OffsetDateTime.ofInstant(
Instant.ofEpochMilli(location.time),
ZoneOffset.systemDefault()).asISO8601String()
driveSegmentsDao.insertSegment(
DBDriveSegment(
driveId = currentDriveId,
latitude = location.latitude.toString(),
longitude = location.longitude.toString(),
dateTimeCreated = dateTime,
)
)
}

This method gets called from the LocationCallback in the MileageService every time we get a location update. Notice that I’m using the java.time APIs. It’s much easier than the old Android Date class and it’s built into the Java version 17 libraries that I’m using for my build, so there’s no need to import some 3rd party library that will eventually stop getting supported. The asISO8601String() method is an OffsetDateTime extension I wrote that converts the date/time to an ISO 8601 formatted string.

suspend fun stopTrackingDrive() = withContext(Dispatchers.IO) {
//calculate the total distance driven
val distanceInMeters = driveDistance.atEndOfDrive()

//update the drive with it's total distance
drivesDao.updateDriveTotalDistanceField(
DBTotalDistance(
id = currentDriveId,
totalDistance = distanceInMeters.toString()
)
)

//reset the current drive id to 0
currentDriveId = INVALID_DB_ID
}

stopTrackingDrive(), the last important DriveRepo method, gets called when the device detects that the drive has ended. Here’s where I ran into my first problem with the Room database — and yes, it’s totally a case of RTFM user error. I was using my full DBDrive class with just the id and totalDistance fields filled out to update the total distance driven during the drive. I passed it to my Room DAO update interface, which looked like this:

@Update
suspend fun updateDriveTotalDistanceField(distance: DBDrive)

After calling it at the end of the drive, all my drive data was wiped out except for the totalDistance. I don’t know why I thought that would work, but I did. After looking back through the docs, it turned out I needed to create a new data class that just had the id and totalDistance fields defined, and to write the interface like this:

@Update(entity = DBDrive::class)
suspend fun updateDriveTotalDistanceField(distance: DBTotalDistance)

That took care of that problem. Duh. But then, I ran into something far more nefarious and less obvious. When I would uninstall the app from my device and then re-install it, as soon as I wrote the first drive to the database, it would show up as entry id 2 and there was a partially completed drive in there as entry id 1. Why was Room creating a phantom database entry? Why!!?

Turns out that Google was trying to be too helpful. From the docs:

Auto Backup for Apps automatically backs up a user’s data from apps that target and run on Android 6.0 (API level 23) or higher. Android preserves app data by uploading it to the user’s Google Drive, where it’s protected by the user’s Google Account credentials.

So, Google was backing up my database and attempting to restore it every time I uninstalled and reinstalled the app. Thank you, Google, but that’s not necessary. You can stop this from the manifest by setting the allowBackup parameter to false:

<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="false"
...
</application>
</manifest>

Ok, now the data is getting saved correctly, the only thing left is to calculate the distance travelled. Easy peasy.

@Singleton
class DriveDistance @Inject constructor() {
private var previousLocation: Location? = null
private var driveDistanceInMeters: Float = 0f
private val _currentDistance = MutableStateFlow("")
val currentDistance: StateFlow<String> = _currentDistance
var distanceDisplayType = DistanceType.MILES

fun calculateCurrentDistance(location: Location) {
previousLocation?.let { prevLocation ->
driveDistanceInMeters += prevLocation.distanceTo(location)
}
previousLocation = location
val newDistance = calculateDistanceForType(driveDistanceInMeters)
emitDriveDistance(newDistance)
}

fun atEndOfDrive() : Float {
val totalDistance = driveDistanceInMeters
_currentDistance.value = ""
previousLocation = null
driveDistanceInMeters = 0f
return totalDistance
}

private fun calculateDistanceForType(distance: Float): String {
val bdDistance = distance.toBigDecimal()
return when (distanceDisplayType) {
DistanceType.MILES -> bdDistance.metersToMiles().toString()
DistanceType.KILOMETERS -> bdDistance.metersToKilometers().toString()
}
}

private fun emitDriveDistance(distance: String) {
_currentDistance.value = distance
}

companion object {
enum class DistanceType(val displayName: String) {
MILES("m"),
KILOMETERS("km"),
}
}
}

In calculateDistanceForType() I’m converting the distance to BigDecimal to avoid any further floating point arithmetic while converting the meters into miles in the following BigDecimal extension:

private const val MILES_CONVERSION = "0.000621371192237"
private const val SCALE = 1

fun BigDecimal.metersToMiles(): BigDecimal =
(this * MILES_CONVERSION.toBigDecimal())
.setScale(SCALE, RoundingMode.HALF_UP)

I’m also storing the conversion value as a String, rather than as a Double or a Float, to avoid any precision errors. For example, 1.7182818284f actually gets shortened to 1.7182819f, because that’s what computers do to floating point numbers. It’s a wonder that anything scientific gets done.

I’m using Google’s Location distanceTo() method to calculate the distance between location and previousLocation in calculateCurrentDistance() and emitting the driveDistanceInMeters, converted to miles or kilometers, as a Flow, which is passed through my DriveRepo class and collected in my main screen view model for display in the UI.

Here’s a screenshot of what I have. I designed the UI myself — and it’s a prime example of why you never let a programmer design a screen. You’re lucky, though. Usually, I’d color it all fuchsia and and wait until it annoyed a designer enough for them to give me a really nice screen design. 😂

I’m going to add the ability to edit the drive name, but after that, I’m calling it done. If you stuck with me through the entire series, I hope you had fun.

Here’s the Github repo, if you’re interested in looking at the full code:

https://github.com/leewaggoner/roadruler

--

--

Leejaywaggoner

A seasoned software developer with decades of experience, sharing deep insights and industry wisdom.