Try it first (no signup): https://drive.google.com/file/d/1EKtKC2EAY_9EKt6VZ-XZj3y36orUPMzw/view?usp=sharing
euexia-demo.apk is 10.6 MB and comes pre-seeded with 8,432 steps, a 517 cal flame streak, and seven days of charts. Swipe it closed, walk 20ΓÇô30 steps, then pull down the notification shade. The count keeps rising while the app is gone.
The problem
Every React/Capacitor health template I looked at did the same thing: call Google Fit, read today's steps, render a ring. It works until the app is killed. Then the WebView is gone, the polling stops, and the number freezes.
Google is also winding Fit down in favour of Health Connect, so building a new paid product on that API in 2026 is a bad bet.
The hardware can already count steps without the app running. Android exposes Sensor.TYPE_STEP_COUNTER, a low-power sensor-hub counter that keeps ticking at the firmware level. The trick is keeping a process alive to listen to it.
The architecture
Sensor.TYPE_STEP_COUNTER Γåô BackgroundStepService (foreground, type=health, START_STICKY) Γåô SharedPreferences ("AppSteps" / "steps") Γåô StepSensorPlugin (Capacitor bridge) Γåô React ΓÇö 5s polling + refresh on visibilitychange/appStateChange Γåô Supabase daily_logs (Postgres + RLS) Four pieces make it work:
- A foreground service owns the sensor, not the WebView.
- The service writes every sensor event to SharedPreferences, so the JS bridge never races the sensor.
- A tiny Capacitor plugin reads that value whenever React asks.
- Android restarts the service because
onStartCommandreturnsSTART_STICKY.
1. Manifest: declare the right foreground-service type
Android 14+ requires a type for every foreground service, and the type must have a matching permission. A pedometer is a health use case, so use health ΓÇö not dataSync. On Android 15, dataSync services are capped at six hours per day, which would silently stop a step counter.
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" /> <service android:name=".BackgroundStepService" android:enabled="true" android:exported="false" android:foregroundServiceType="health" /> ACTIVITY_RECOGNITION is a runtime permission on Android 10+. Request it from MainActivity before starting the service.
2. The service: register the hardware sensor and never let go
public class BackgroundStepService extends Service implements SensorEventListener { private static final String CHANNEL_ID = "app_step_channel"; private static final int NOTIF_ID = 1001; private int todaySteps = 0; private int lastSensorValue = -1; private SharedPreferences prefs; @Override public void onCreate() { super.onCreate(); prefs = getSharedPreferences("AppSteps", MODE_PRIVATE); todaySteps = prefs.getInt("steps", 0); lastSensorValue = prefs.getInt("lastSensorValue", -1); createNotificationChannel(); if (Build.VERSION.SDK_INT >= 34) { startForeground(NOTIF_ID, buildNotification(todaySteps), ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH); } else { startForeground(NOTIF_ID, buildNotification(todaySteps)); } SensorManager sm = (SensorManager) getSystemService(SENSOR_SERVICE); Sensor step = sm.getDefaultSensor(Sensor.TYPE_STEP_COUNTER); sm.registerListener(this, step, SensorManager.SENSOR_DELAY_UI); } @Override public int onStartCommand(Intent i, int flags, int startId) { return START_STICKY; // Android restarts us if it kills the process } @Override public void onSensorChanged(SensorEvent event) { int raw = (int) event.values[0]; // TYPE_STEP_COUNTER is cumulative since boot. A lower value means reboot. if (lastSensorValue == -1 || raw < lastSensorValue) lastSensorValue = raw; int delta = raw - lastSensorValue; lastSensorValue = raw; // Optional: reset the logical day at 5:00 AM, not midnight. rolloverIfNewDay(); todaySteps += delta; prefs.edit() .putInt("steps", todaySteps) .putInt("lastSensorValue", lastSensorValue) .apply(); updateNotification(todaySteps); } } Two details that bit me:
-
TYPE_STEP_COUNTERis cumulative since boot, not "today". You have to subtract the previous raw value and persist it. Handleraw < lastValueas a reboot. - The service must write to storage on every event. If React tried to register its own listener, it would die with the WebView.
3. The bridge: let React ask for the number
@CapacitorPlugin(name = "StepSensor") public class StepSensorPlugin extends Plugin { @PluginMethod public void getSteps(PluginCall call) { SharedPreferences prefs = getContext() .getSharedPreferences("AppSteps", Context.MODE_PRIVATE); int steps = prefs.getInt("steps", 0); JSObject ret = new JSObject(); ret.put("value", steps); call.resolve(ret); } } Register it in MainActivity before super.onCreate:
@Override public void onCreate(Bundle savedInstanceState) { registerPlugin(StepSensorPlugin.class); super.onCreate(savedInstanceState); } 4. React: poll while visible, refresh the instant you resume
const StepSensor = Capacitor.isNativePlatform() ? registerPlugin("StepSensor") : null; const fetchNativeSteps = async () => { if (!StepSensor) return; const { value } = await StepSensor.getSteps(); setSteps(value); }; useEffect(() => { fetchNativeSteps(); const id = setInterval(fetchNativeSteps, 5000); const onVisible = () => { if (document.visibilityState === "visible") fetchNativeSteps(); }; document.addEventListener("visibilitychange", onVisible); return () => { clearInterval(id); document.removeEventListener("visibilitychange", onVisible); }; }, []); Guard the plugin registration with Capacitor.isNativePlatform() so the browser build does not throw.
Gotchas I hit on real devices
- Emulators do not emit hardware step events. You need a physical phone.
- OEM battery managers (Xiaomi/MIUI, Samsung) may still kill the service.
START_STICKYhelps; asking users to disable battery optimisation helps more. - The persistent notification is mandatory on Android 8+. Do not try to hide it; Google Fit, Samsung Health, and Fitbit all show one.
- Day rollover must happen inside the service, because the app may not be open at 5:00 AM.
- Restarting the service on boot is a separate
BOOT_COMPLETEDreceiver; add it if you need counting to resume before the user opens the app.
Try it, then break it
Install the demo, close it, walk, reopen it:
https://drive.google.com/file/d/1EKtKC2EAY_9EKt6VZ-XZj3y36orUPMzw/view?usp=sharing
If the count kept moving, the service is doing its job.
I packaged this architecture, the Supabase schema, auth, water/workout/streak screens, and the setup guide into a production React 19.2 + Capacitor template. If you would rather clone it than rebuild the native layer:
https://theoria0.gumroad.com/l/euexia-health-app-template
I'm the author of the template. Happy to answer architecture questions in the comments.