expo-android-pedometer
v2.0.0
Published
Get steps count on Android devices
Maintainers
Readme
expo-android-pedometer
An Expo module that counts steps on Android with the built-in step counter sensor. It keeps counting in the background through a foreground service, stores per-minute history on the device, and writes steps to Health Connect when the app already holds the permission.
Features
- Background step counting with a persistent notification
- Per-minute step history for the last 30 days
- Hourly totals grouped by local time, including half-hour time zones and DST days
- Steps walked across midnight, after a reboot, or while the service was stopped are still counted
- Automatic Health Connect writes when
WRITE_STEPSis already granted - Self-restoring tracking after reboots, app updates and OEM task killers
Installation
npx expo install expo-android-pedometerAdd the config plugin to app.json:
{
"expo": {
"plugins": ["expo-android-pedometer"]
}
}The plugin adds the required permissions and raises minSdkVersion to 26. Pass { "isBatteryExemptionEnabled": false } to leave out REQUEST_IGNORE_BATTERY_OPTIMIZATIONS.
Quick start
import * as Pedometer from 'expo-android-pedometer';
async function startTracking() {
if (!(await Pedometer.isSensorAvailable())) {
return;
}
const activity = await Pedometer.requestActivityPermissions();
if (!activity.granted) {
return;
}
await Pedometer.requestNotificationPermissions();
await Pedometer.setupBackgroundUpdates({
title: 'Step Counter',
contentTemplate: "You've taken %d steps today",
});
const subscription = Pedometer.subscribeToChanges((event) => {
console.log(`Steps today: ${event.steps}`);
});
const today = await Pedometer.getStepsCountAsync();
console.log(`Today's steps: ${today}`);
return subscription;
}API
All functions are exported from expo-android-pedometer. On iOS they resolve to empty or false values.
Sensor and permissions
isSensorAvailable(): Promise<boolean>
Resolves to true when the device has a step counter sensor.
requestActivityPermissions(): Promise<PermissionResponse>
Requests ACTIVITY_RECOGNITION on Android 10 and above, and FOREGROUND_SERVICE_HEALTH on Android 14 and above.
getActivityPermissionStatus(): Promise<PermissionResponse>
Returns the current ACTIVITY_RECOGNITION status.
requestNotificationPermissions(): Promise<PermissionResponse>
Requests POST_NOTIFICATIONS on Android 13 and above. Tracking works without it, but the notification and the restore prompt stay hidden.
getNotificationPermissionStatus(): Promise<PermissionResponse>
Returns the current POST_NOTIFICATIONS status.
PermissionResponse comes from expo-modules-core:
type PermissionResponse = {
status: 'granted' | 'denied' | 'undetermined';
granted: boolean;
expires: 'never' | number;
canAskAgain: boolean;
};Background tracking
setupBackgroundUpdates(config: NotificationConfig): Promise<boolean>
Turns on background tracking and starts the foreground service. Tracking stays on across app restarts, reboots and app updates until you call stopBackgroundUpdates(). Calling it again while tracking updates the notification text.
Resolves to true when the service started. Rejects with MISSING_PERMISSIONS when the activity permission is missing, and with INVALID_CONFIG when contentTemplate has no %d.
type NotificationConfig = {
title?: string; // Notification title. Default: "Step Counter"
contentTemplate: string; // Notification text; %d is replaced with today's steps
pausedTitle?: string; // Title of the restore prompt. Default: "Step tracking paused"
pausedText?: string; // Text of the restore prompt. Default: "Tap to resume counting steps"
};stopBackgroundUpdates(): Promise<boolean>
Turns off background tracking. It stops the service, removes the notification, and cancels the watchdog and restart alarms. Stored history is kept. Steps walked while tracking is off are not counted.
getTrackingStatus(): Promise<TrackingStatus>
type TrackingStatus = {
isEnabled: boolean; // setupBackgroundUpdates was called and not stopped
isRunning: boolean; // the foreground service is running now
isBatteryOptimizationExempt: boolean;
lastSensorReadingAt: number | null; // epoch millis of the last accepted sensor reading
};subscribeToChanges(listener: (event: StepCountChangedEvent) => void): EventSubscription
Calls listener with today's total whenever it changes while the service runs. Call subscription.remove() to stop listening.
type StepCountChangedEvent = {
steps: number; // steps today
timestamp: number; // epoch millis of the update
};Step history
History is stored per minute and kept for 30 days.
getStepsCountAsync(dateIsoString?: string): Promise<number>
Returns the total for the local day that contains dateIsoString, or for today when omitted.
const today = await Pedometer.getStepsCountAsync();
const yesterday = await Pedometer.getStepsCountAsync(subDays(new Date(), 1).toISOString());getStepsCountInRangeAsync(startIso: string, endIso: string): Promise<StepRecord[]>
Returns one record per minute with steps, for minutes that start between startIso and endIso (both inclusive), sorted by time.
type StepRecord = {
timestamp: number; // start of the minute, epoch millis
steps: number;
};getHourlyStepsForRange(startIso: string, endIso: string): Promise<HourlyStepData[]>
Returns hourly totals grouped by local hour, sorted by time. Hours without steps are left out.
type HourlyStepData = {
hourStart: number; // start of the local hour, epoch millis
steps: number;
};Keeping tracking alive
isBatteryOptimizationExcluded(): Promise<boolean>
Resolves to true when the app is exempt from battery optimization, or when the device has no such setting.
requestBatteryOptimizationExemption(): Promise<boolean>
Opens the system prompt to exempt the app from battery optimization. Resolves to false when the prompt is not available.
canOpenAutostartSettings(): Promise<boolean>
Resolves to true on devices with a known autostart or background-activity screen: Xiaomi, Redmi, POCO, Huawei, Honor, Oppo, Realme, OnePlus, Vivo, iQOO, Samsung, Asus, Letv, Meizu and Nokia.
openAutostartSettings(): Promise<boolean>
Opens that screen. Resolves to false when none of the known screens could be opened.
Health Connect
getHealthConnectDeviceDataOrigin(): Promise<string | null>
Returns the package name Health Connect uses as the data origin for this device, or null when unsupported.
The module writes one Health Connect steps record per minute. It never asks for Health Connect permission. Request android.permission.health.WRITE_STEPS in your app, for example with react-native-health-connect. Steps recorded before the permission is granted are written once it is granted. Writes are retried until they succeed, and a retry never creates a duplicate record.
Keeping tracking alive
Android and many vendor builds stop background services to save battery. The module recovers in these ways:
- Periodic watchdog. A WorkManager job runs about every 15 minutes while tracking is on. It restarts the service if it stopped, and it reads the step counter once so steps walked while the service was down are still counted. The same job writes pending steps to Health Connect and removes history older than 30 days.
- Restart signals. The service restarts after a reboot (including vendor fast boot), after an app update, when the app opens, and a few seconds after the system removes it.
- Restore prompt. Android 12 and above block starting a foreground service from the background in most cases. When that happens, the module posts a notification with
pausedTitleandpausedText. Tapping it resumes tracking.
The step counter keeps counting while the service is stopped, so the steps are counted when tracking resumes. They are spread evenly over the time the service was down, which makes hourly totals for that period approximate.
For the most reliable tracking, ask users to:
- Exempt the app from battery optimization with
requestBatteryOptimizationExemption(). - Allow autostart or background activity with
openAutostartSettings()whencanOpenAutostartSettings()istrue. - Keep the notification permission on, so the restore prompt can appear.
Testing on an emulator
Emulators have no step counter sensor. In debug builds on such a device, the module uses a fake counter that you drive with adb. Release builds never include it.
Replace com.example.app with your application ID. The example app uses expo.modules.androidpedometer.example.
# Grant the activity permission without the dialog
adb shell pm grant com.example.app android.permission.ACTIVITY_RECOGNITION
# Walk 100 steps
adb shell am broadcast -a expo.modules.androidpedometer.debug.FAKE_STEPS -p com.example.app --ei add 100
# Set the raw counter; a value lower than before simulates a sensor reset
adb shell am broadcast -a expo.modules.androidpedometer.debug.FAKE_STEPS -p com.example.app --el raw 20
# Reboot; the fake counter restarts from 0, like the hardware counter
adb rebootThe fake counter keeps its value across process kills. It resets only when the device boot count changes.
scripts/e2e-emulator.sh runs the reliability scenarios against the example app: process kill, blocked background restart and the resume prompt, restart with a battery exemption, a gap across midnight, the watchdog job, and stopping tracking. It needs a rooted emulator image (Google APIs, not Play Store) on Android 12 or newer, the example debug build installed, and Metro running. It clears the example app's data first. Set E2E_REBOOT=1 to include a reboot.
cd example/android && ./gradlew :app:installDebug
cd .. && bunx expo start # in another terminal
E2E_REBOOT=1 ./scripts/e2e-emulator.shNotes
- Requires Android 8.0 (API 26) or newer and a hardware step counter sensor.
- Step data stays on the device, apart from the Health Connect writes.
- Stopping tracking with
stopBackgroundUpdates()does not delete history.
License
MIT
