Counting steps on Android without lying to your users
Google is retiring Fit, which means a lot of people are about to need something else to count their steps. It turns out the counting is the easy part. Making the number look alive is where you lose a day.
There is no step API. There are two sensors.
You do not need Google Play Services, Health Connect, or anyone's SDK to count steps. Android has had the sensors since API 19, in android.hardware.Sensor:
TYPE_STEP_COUNTER returns the total steps since the device booted. It is cumulative, it is maintained by low-power hardware, and it keeps counting whether your app is running or not.
TYPE_STEP_DETECTOR fires one event per step, as it happens.
The obvious choice is the counter. It survives your process being killed, it does not care about your battery, and it is the source of truth. So you register a listener, write the delta to a database, and you are done in about forty lines.
Then you walk around the room watching your own app and the number does not move.
The batching problem
To save power, the sensor hub collects step events in its own memory and hands them to you in batches. On the phone I tested, that was up to thirty seconds of walking arriving at once.
Your daily total is completely correct. It is just late.
This is a hardware behaviour, not a setting. It is not battery saver, it is not doze, and the user cannot turn it off — which matters, because your instinct will be to tell them to change a setting, and that will not help.
But from the user's side, a step counter that does not move while they are visibly walking is broken. They have no way to tell "buffered" from "crashed", and they will not give you the benefit of the doubt. Google Fit lags the same way on the same phone, but nobody blames Fit, because nobody is auditing Fit — they are auditing the new app they just installed.
The fix: two sensors doing different jobs
Run both.
The detector fires immediately, so use it to move the number optimistically as the user walks. The counter is authoritative, so use it to reconcile whenever a batch lands.
// Detector: fires per step. Drives the display, never the database.
private var pending = 0L
override fun onSensorChanged(e: SensorEvent) {
when (e.sensor.type) {
Sensor.TYPE_STEP_DETECTOR -> {
pending++
onSteps(committed + pending) // optimistic, immediate
}
Sensor.TYPE_STEP_COUNTER -> {
committed = store.recordReading(e.values[0].toLong())
pending = 0 // the batch landed; truth wins
onSteps(committed)
}
}
}
The detector never writes to storage. If it did, a batch arriving afterwards would count the same steps twice. It exists purely so the number on screen moves while a human is watching it.
Turning a cumulative counter into daily rows
The counter is steps since boot, which is not what anyone wants to see. You store deltas:
fun recordReading(raw: Long, now: Date = Date()): Long {
val previous = getState(KEY_LAST_RAW)?.toLongOrNull()
setState(KEY_LAST_RAW, raw.toString())
// First ever reading establishes a BASELINE, not a step count.
if (previous == null) return 0
// Counter went backwards => the device rebooted. Everything it reports
// was walked since boot, so the raw value IS the delta.
val delta = if (raw < previous) raw else raw - previous
if (delta <= 0) return 0
addSteps(dayKey(now), delta)
return delta
}
Two things there cost real time to learn.
The first reading is a baseline. If you treat it as a count, the user installs your app and is immediately credited with every step since they last rebooted — which on a phone that has been up for a week is a spectacular number and obviously wrong.
A reboot resets the counter to zero. If you subtract naively you get a large negative delta. Detect the decrease and treat the raw value as the delta, because that is exactly what it is.
Tell the user what is happening
Here is the part I would not have predicted: the highest-value thing I built was a line of text that reports nothing.
Once the number is only usually live, the user needs a way to tell "still working" from "frozen". So there is a ticker that updates every second:
updated 3 seconds ago
It conveys no new information. Its entire job is to remove an ambiguity — and it is the difference between a user thinking the app is buffering and thinking it is broken.
Alongside it, on devices where batching is actually detected, a tappable badge that explains the lag in plain language. The wording is deliberate: it never says "accuracy", it never blames battery saver, and it never implies the app is broken. It says the total is correct and only the refresh is late, because that is the truth and it is also the reassuring version.
What you do not need
No Play Services. No Health Connect — that stores what other apps write, so reading it makes you a viewer rather than a replacement. No network. No background service.
You need ACTIVITY_RECOGNITION on API 29 and above, a WorkManager job to sample periodically so the total stays current between launches, and about two hundred lines.
The honest limitations
Step sensors count arm swings that look like steps. Pushing a trolley or holding your phone still undercounts. Distance from step count is an estimate, and the app should say so rather than printing a confident decimal — your stride is not a constant, it changes with how fast you are walking, which is a whole article of its own.
But for "did I move today", the hardware is good and it is free, and it will keep working after Fit is gone.
