react-native-step-tracker-pro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/android/build.gradle +119 -0
  4. package/android/consumer-rules.pro +13 -0
  5. package/android/gradle.properties +3 -0
  6. package/android/src/main/AndroidManifest.xml +78 -0
  7. package/android/src/main/java/com/steptrackerpro/StepTrackerProModule.kt +598 -0
  8. package/android/src/main/java/com/steptrackerpro/StepTrackerProPackage.kt +26 -0
  9. package/android/src/main/java/com/steptrackerpro/core/DateKeys.kt +90 -0
  10. package/android/src/main/java/com/steptrackerpro/core/GoalTracker.kt +66 -0
  11. package/android/src/main/java/com/steptrackerpro/core/MetricsCalculator.kt +48 -0
  12. package/android/src/main/java/com/steptrackerpro/core/Models.kt +73 -0
  13. package/android/src/main/java/com/steptrackerpro/core/StepCounterEngine.kt +338 -0
  14. package/android/src/main/java/com/steptrackerpro/core/StepStateStore.kt +137 -0
  15. package/android/src/main/java/com/steptrackerpro/core/StepTrackerConfig.kt +184 -0
  16. package/android/src/main/java/com/steptrackerpro/core/StepTrackerCore.kt +280 -0
  17. package/android/src/main/java/com/steptrackerpro/db/Entities.kt +67 -0
  18. package/android/src/main/java/com/steptrackerpro/db/StepDao.kt +118 -0
  19. package/android/src/main/java/com/steptrackerpro/db/StepDatabase.kt +60 -0
  20. package/android/src/main/java/com/steptrackerpro/db/StepRepository.kt +146 -0
  21. package/android/src/main/java/com/steptrackerpro/health/HealthConnectManager.kt +198 -0
  22. package/android/src/main/java/com/steptrackerpro/health/HealthPermissionActivity.kt +99 -0
  23. package/android/src/main/java/com/steptrackerpro/service/BootReceiver.kt +54 -0
  24. package/android/src/main/java/com/steptrackerpro/service/NotificationActionReceiver.kt +17 -0
  25. package/android/src/main/java/com/steptrackerpro/service/NotificationFactory.kt +170 -0
  26. package/android/src/main/java/com/steptrackerpro/service/ServiceCommands.kt +42 -0
  27. package/android/src/main/java/com/steptrackerpro/service/StepTrackerService.kt +409 -0
  28. package/android/src/main/java/com/steptrackerpro/sync/SyncScheduler.kt +102 -0
  29. package/android/src/main/java/com/steptrackerpro/sync/SyncWorkers.kt +175 -0
  30. package/android/src/main/java/com/steptrackerpro/util/BatteryOptimizationHelper.kt +94 -0
  31. package/android/src/main/java/com/steptrackerpro/util/Bridge.kt +149 -0
  32. package/android/src/main/java/com/steptrackerpro/util/PermissionHelper.kt +92 -0
  33. package/android/src/main/java/com/steptrackerpro/util/StepEventBus.kt +53 -0
  34. package/android/src/main/res/drawable/stp_ic_steps.xml +9 -0
  35. package/android/src/main/res/values/strings.xml +9 -0
  36. package/android/src/main/res/values/styles.xml +8 -0
  37. package/android/src/newarch/java/com/steptrackerpro/StepTrackerProSpec.kt +10 -0
  38. package/android/src/oldarch/java/com/steptrackerpro/StepTrackerProSpec.kt +60 -0
  39. package/docs/API.md +329 -0
  40. package/docs/ARCHITECTURE.md +203 -0
  41. package/docs/INSTALLATION.md +175 -0
  42. package/docs/PLAY_STORE_COMPLIANCE.md +122 -0
  43. package/docs/TESTING.md +312 -0
  44. package/docs/TROUBLESHOOTING.md +96 -0
  45. package/lib/commonjs/NativeStepTrackerPro.js +14 -0
  46. package/lib/commonjs/NativeStepTrackerPro.js.map +1 -0
  47. package/lib/commonjs/StepTracker.js +290 -0
  48. package/lib/commonjs/StepTracker.js.map +1 -0
  49. package/lib/commonjs/constants.js +30 -0
  50. package/lib/commonjs/constants.js.map +1 -0
  51. package/lib/commonjs/errors.js +24 -0
  52. package/lib/commonjs/errors.js.map +1 -0
  53. package/lib/commonjs/hooks/index.js +20 -0
  54. package/lib/commonjs/hooks/index.js.map +1 -0
  55. package/lib/commonjs/hooks/useStepStats.js +51 -0
  56. package/lib/commonjs/hooks/useStepStats.js.map +1 -0
  57. package/lib/commonjs/hooks/useStepTracker.js +134 -0
  58. package/lib/commonjs/hooks/useStepTracker.js.map +1 -0
  59. package/lib/commonjs/index.js +64 -0
  60. package/lib/commonjs/index.js.map +1 -0
  61. package/lib/commonjs/package.json +1 -0
  62. package/lib/commonjs/types.js +2 -0
  63. package/lib/commonjs/types.js.map +1 -0
  64. package/lib/module/NativeStepTrackerPro.js +12 -0
  65. package/lib/module/NativeStepTrackerPro.js.map +1 -0
  66. package/lib/module/StepTracker.js +284 -0
  67. package/lib/module/StepTracker.js.map +1 -0
  68. package/lib/module/constants.js +26 -0
  69. package/lib/module/constants.js.map +1 -0
  70. package/lib/module/errors.js +18 -0
  71. package/lib/module/errors.js.map +1 -0
  72. package/lib/module/hooks/index.js +5 -0
  73. package/lib/module/hooks/index.js.map +1 -0
  74. package/lib/module/hooks/useStepStats.js +46 -0
  75. package/lib/module/hooks/useStepStats.js.map +1 -0
  76. package/lib/module/hooks/useStepTracker.js +129 -0
  77. package/lib/module/hooks/useStepTracker.js.map +1 -0
  78. package/lib/module/index.js +7 -0
  79. package/lib/module/index.js.map +1 -0
  80. package/lib/module/package.json +1 -0
  81. package/lib/module/types.js +2 -0
  82. package/lib/module/types.js.map +1 -0
  83. package/lib/typescript/NativeStepTrackerPro.d.ts +50 -0
  84. package/lib/typescript/NativeStepTrackerPro.d.ts.map +1 -0
  85. package/lib/typescript/StepTracker.d.ts +76 -0
  86. package/lib/typescript/StepTracker.d.ts.map +1 -0
  87. package/lib/typescript/constants.d.ts +10 -0
  88. package/lib/typescript/constants.d.ts.map +1 -0
  89. package/lib/typescript/errors.d.ts +8 -0
  90. package/lib/typescript/errors.d.ts.map +1 -0
  91. package/lib/typescript/hooks/index.d.ts +5 -0
  92. package/lib/typescript/hooks/index.d.ts.map +1 -0
  93. package/lib/typescript/hooks/useStepStats.d.ts +11 -0
  94. package/lib/typescript/hooks/useStepStats.d.ts.map +1 -0
  95. package/lib/typescript/hooks/useStepTracker.d.ts +26 -0
  96. package/lib/typescript/hooks/useStepTracker.d.ts.map +1 -0
  97. package/lib/typescript/index.d.ts +7 -0
  98. package/lib/typescript/index.d.ts.map +1 -0
  99. package/lib/typescript/types.d.ts +183 -0
  100. package/lib/typescript/types.d.ts.map +1 -0
  101. package/package.json +79 -0
  102. package/src/NativeStepTrackerPro.ts +66 -0
  103. package/src/StepTracker.ts +410 -0
  104. package/src/constants.ts +43 -0
  105. package/src/errors.ts +31 -0
  106. package/src/hooks/index.ts +4 -0
  107. package/src/hooks/useStepStats.ts +55 -0
  108. package/src/hooks/useStepTracker.ts +167 -0
  109. package/src/index.ts +27 -0
  110. package/src/types.ts +203 -0
@@ -0,0 +1,8 @@
1
+ <resources>
2
+ <style name="StepTrackerPro.Transparent" parent="android:Theme.Translucent.NoTitleBar">
3
+ <item name="android:windowBackground">@android:color/transparent</item>
4
+ <item name="android:windowIsTranslucent">true</item>
5
+ <item name="android:windowNoTitle">true</item>
6
+ <item name="android:backgroundDimEnabled">false</item>
7
+ </style>
8
+ </resources>
@@ -0,0 +1,10 @@
1
+ package com.steptrackerpro
2
+
3
+ import com.facebook.react.bridge.ReactApplicationContext
4
+
5
+ /**
6
+ * New architecture shim. `NativeStepTrackerProSpec` is generated by codegen
7
+ * from `src/NativeStepTrackerPro.ts` at build time.
8
+ */
9
+ abstract class StepTrackerProSpec internal constructor(context: ReactApplicationContext) :
10
+ NativeStepTrackerProSpec(context)
@@ -0,0 +1,60 @@
1
+ package com.steptrackerpro
2
+
3
+ import com.facebook.react.bridge.Promise
4
+ import com.facebook.react.bridge.ReactApplicationContext
5
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
6
+ import com.facebook.react.bridge.ReadableMap
7
+
8
+ /**
9
+ * Old architecture shim. Mirrors the signatures codegen produces for the new
10
+ * architecture so `StepTrackerProModule` compiles unchanged against both.
11
+ */
12
+ abstract class StepTrackerProSpec internal constructor(context: ReactApplicationContext) :
13
+ ReactContextBaseJavaModule(context) {
14
+
15
+ abstract fun initialize(config: ReadableMap, promise: Promise)
16
+ abstract fun updateConfig(config: ReadableMap, promise: Promise)
17
+ abstract fun getConfig(promise: Promise)
18
+ abstract fun startTracking(promise: Promise)
19
+ abstract fun pauseTracking(promise: Promise)
20
+ abstract fun resumeTracking(promise: Promise)
21
+ abstract fun stopTracking(promise: Promise)
22
+ abstract fun getTrackingState(promise: Promise)
23
+ abstract fun isTracking(promise: Promise)
24
+
25
+ abstract fun getTodaySteps(promise: Promise)
26
+ abstract fun getStepsForDate(date: String, promise: Promise)
27
+ abstract fun getYesterdaySteps(promise: Promise)
28
+ abstract fun getStatsForRange(startDate: String, endDate: String, promise: Promise)
29
+ abstract fun getWeeklyStats(options: ReadableMap, promise: Promise)
30
+ abstract fun getMonthlyStats(options: ReadableMap, promise: Promise)
31
+ abstract fun getYearlyStats(options: ReadableMap, promise: Promise)
32
+ abstract fun getHistory(startDate: String, endDate: String, promise: Promise)
33
+
34
+ abstract fun resetToday(promise: Promise)
35
+ abstract fun clearHistory(promise: Promise)
36
+ abstract fun pruneHistory(retentionDays: Double, promise: Promise)
37
+
38
+ abstract fun checkPermissions(promise: Promise)
39
+ abstract fun requestPermissions(promise: Promise)
40
+ abstract fun getDeviceCapabilities(promise: Promise)
41
+ abstract fun openAppSettings(promise: Promise)
42
+
43
+ abstract fun isBatteryOptimizationEnabled(promise: Promise)
44
+ abstract fun requestDisableBatteryOptimization(promise: Promise)
45
+ abstract fun openBatteryOptimizationSettings(promise: Promise)
46
+ abstract fun openManufacturerAutoStartSettings(promise: Promise)
47
+
48
+ abstract fun getHealthConnectStatus(promise: Promise)
49
+ abstract fun requestHealthConnectPermissions(promise: Promise)
50
+ abstract fun openHealthConnectSettings(promise: Promise)
51
+ abstract fun readHealthConnectSteps(startIso: String, endIso: String, promise: Promise)
52
+ abstract fun writeHealthConnectSteps(date: String, promise: Promise)
53
+ abstract fun syncWithHealthConnect(promise: Promise)
54
+
55
+ abstract fun getPendingSyncCount(promise: Promise)
56
+ abstract fun syncNow(promise: Promise)
57
+
58
+ abstract fun addListener(eventName: String)
59
+ abstract fun removeListeners(count: Double)
60
+ }
package/docs/API.md ADDED
@@ -0,0 +1,329 @@
1
+ # API reference
2
+
3
+ Every method returns a promise and rejects with a `StepTrackerError` carrying a
4
+ stable `code`, so you can branch without matching on message strings.
5
+
6
+ ```ts
7
+ import StepTracker, { StepTrackerError } from 'react-native-step-tracker-pro';
8
+
9
+ try {
10
+ await StepTracker.startTracking();
11
+ } catch (error) {
12
+ if ((error as StepTrackerError).code === 'E_PERMISSION_DENIED') { /* ... */ }
13
+ }
14
+ ```
15
+
16
+ Codes: `E_UNSUPPORTED_PLATFORM`, `E_NO_SENSOR`, `E_NOT_INITIALIZED`,
17
+ `E_PERMISSION_DENIED`, `E_SERVICE_START_FAILED`, `E_HEALTH_CONNECT_UNAVAILABLE`,
18
+ `E_HEALTH_CONNECT_DENIED`, `E_DATABASE`, `E_INVALID_CONFIG`, `E_NO_ACTIVITY`,
19
+ `E_UNKNOWN`.
20
+
21
+ ---
22
+
23
+ ## Lifecycle
24
+
25
+ ### `initialize(config?: StepTrackerConfig): Promise<StepSnapshot>`
26
+
27
+ Persists config natively and reconciles the counter against the current boot and
28
+ date. Idempotent — call it on every app launch, before anything else. Config is
29
+ stored in SharedPreferences, so the foreground service can rebuild it after a
30
+ process restart with no JS running.
31
+
32
+ ### `updateConfig(config: StepTrackerConfig): Promise<StepTrackerConfig>`
33
+
34
+ Patches config at runtime. Goals and the notification update immediately.
35
+ Changing `height` or `sex` recomputes stride length unless you pass an explicit
36
+ `strideLength`.
37
+
38
+ ### `startTracking(): Promise<StepSnapshot>`
39
+
40
+ Starts the foreground service. Rejects with `E_PERMISSION_DENIED` if
41
+ `ACTIVITY_RECOGNITION` is missing, `E_NO_SENSOR` if the device has neither step
42
+ sensor.
43
+
44
+ ### `pauseTracking()` / `resumeTracking(): Promise<StepSnapshot>`
45
+
46
+ The service and notification stay alive; sensor deltas are folded into the
47
+ anchor instead of the total, so paused steps are discarded rather than buffered.
48
+
49
+ ### `stopTracking(): Promise<StepSnapshot>`
50
+
51
+ Flushes to the database, unregisters the sensors, cancels sync work, kills the
52
+ service, and clears the auto-start-on-boot flag.
53
+
54
+ ### `getTrackingState(): Promise<TrackingState>`
55
+
56
+ `'idle' | 'running' | 'paused' | 'stopped' | 'unsupported'`.
57
+
58
+ ### `isTracking(): Promise<boolean>`
59
+
60
+ ---
61
+
62
+ ## Reading steps
63
+
64
+ ### `getTodaySteps(): Promise<StepSnapshot>`
65
+
66
+ ```ts
67
+ {
68
+ date: '2026-09-06',
69
+ steps: 7431,
70
+ distance: 5231.4, // metres
71
+ calories: 223.1, // kcal
72
+ dailyGoal: 10000,
73
+ goalProgress: 0.7431, // 0..1
74
+ goalReached: false,
75
+ state: 'running',
76
+ source: 'step_counter',
77
+ timestamp: 1757145600000
78
+ }
79
+ ```
80
+
81
+ Live from the counter, not the database — correct even if the last flush was
82
+ several steps ago.
83
+
84
+ ### `getYesterdaySteps(): Promise<DayRecord>`
85
+ ### `getStepsForDate(date: string): Promise<DayRecord>`
86
+
87
+ `date` is `yyyy-MM-dd` in the device timezone.
88
+
89
+ ```ts
90
+ { date: '2026-09-05', steps: 11204, distance: 7887.6, calories: 336.4, synced: true }
91
+ ```
92
+
93
+ ### `getWeeklyStats(options?)` / `getMonthlyStats(options?)` / `getYearlyStats(options?)`
94
+
95
+ ```ts
96
+ type RangeOptions = {
97
+ mode?: 'calendar' | 'rolling'; // default 'calendar'
98
+ offset?: number; // 0 = current, 1 = previous. Calendar mode only.
99
+ };
100
+ ```
101
+
102
+ `calendar` snaps to the Monday-anchored ISO week, the calendar month, or the
103
+ calendar year. `rolling` uses the last 7 / 30 / 365 days ending today.
104
+
105
+ ```ts
106
+ {
107
+ startDate: '2026-08-31',
108
+ endDate: '2026-09-06',
109
+ totalSteps: 54210,
110
+ totalDistance: 38160.4,
111
+ totalCalories: 1630.2,
112
+ averageSteps: 9035, // over elapsed days, not the whole window
113
+ activeDays: 6,
114
+ bestDay: { date: '2026-09-03', steps: 14002, ... },
115
+ days: [ /* zero-filled, ascending, today merged live */ ],
116
+ goal: 70000,
117
+ goalProgress: 0.774
118
+ }
119
+ ```
120
+
121
+ `averageSteps` divides by elapsed days so a half-finished month is not diluted
122
+ by days that have not happened yet.
123
+
124
+ ### `getStatsForRange(startDate, endDate): Promise<RangeStats>`
125
+ ### `getHistory(startDate, endDate): Promise<DayRecord[]>`
126
+
127
+ Raw rows, zero-filled. Use this to draw charts; use `getStatsForRange` when you
128
+ want the aggregates computed for you.
129
+
130
+ ---
131
+
132
+ ## Writes
133
+
134
+ ### `resetToday(): Promise<boolean>`
135
+
136
+ Zeroes today's counter and re-arms goal events. History is untouched. Meant for
137
+ QA builds.
138
+
139
+ ### `clearHistory(): Promise<boolean>`
140
+ ### `pruneHistory(retentionDays?): Promise<number>`
141
+
142
+ Deletes rows older than the window and returns how many were removed. Runs
143
+ automatically at midnight and once a day via WorkManager, using
144
+ `historyRetentionDays` from config.
145
+
146
+ ---
147
+
148
+ ## Permissions
149
+
150
+ ### `requestPermissions(): Promise<PermissionStatus>`
151
+
152
+ Shows the runtime dialogs for `ACTIVITY_RECOGNITION` and, on Android 13+,
153
+ `POST_NOTIFICATIONS`. Needs a foreground activity; rejects with `E_NO_ACTIVITY`
154
+ otherwise.
155
+
156
+ ```ts
157
+ {
158
+ activityRecognition: true,
159
+ postNotifications: true,
160
+ foregroundServiceHealth: true, // always true below API 34
161
+ allGranted: true
162
+ }
163
+ ```
164
+
165
+ `allGranted` deliberately ignores `POST_NOTIFICATIONS` — denying it hides the
166
+ notification but does not stop counting.
167
+
168
+ ### `checkPermissions(): Promise<PermissionStatus>`
169
+ ### `getDeviceCapabilities(): Promise<DeviceCapabilities>`
170
+
171
+ ```ts
172
+ { hasStepCounter: true, hasStepDetector: true, supported: true,
173
+ sdkInt: 35, manufacturer: 'samsung', model: 'SM-S928B' }
174
+ ```
175
+
176
+ Check this before showing a step UI at all. A handful of low-end devices and
177
+ most emulators have no step hardware.
178
+
179
+ ### `openAppSettings(): Promise<boolean>`
180
+
181
+ ---
182
+
183
+ ## Battery
184
+
185
+ ### `isBatteryOptimizationEnabled(): Promise<boolean>`
186
+
187
+ True means the OS is still applying Doze restrictions to your app.
188
+
189
+ ### `requestDisableBatteryOptimization(): Promise<boolean>`
190
+
191
+ Shows the system exemption dialog. Requires
192
+ `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` and an eligible Play use case — read
193
+ [PLAY_STORE_COMPLIANCE.md](PLAY_STORE_COMPLIANCE.md) first.
194
+
195
+ ### `openBatteryOptimizationSettings(): Promise<boolean>`
196
+
197
+ Policy-safe alternative: opens the settings list and lets the user choose.
198
+
199
+ ### `openManufacturerAutoStartSettings(): Promise<boolean>`
200
+
201
+ Best-effort deep link into Xiaomi / Oppo / Vivo / Huawei autostart screens,
202
+ falling back to app info. On those skins this matters more than Doze.
203
+
204
+ ---
205
+
206
+ ## Health Connect
207
+
208
+ ### `getHealthConnectStatus(): Promise<HealthConnectStatus>`
209
+
210
+ ```ts
211
+ { available: true, requiresUpdate: false, granted: false, grantedPermissions: [] }
212
+ ```
213
+
214
+ ### `requestHealthConnectPermissions(): Promise<HealthConnectStatus>`
215
+
216
+ Launches the provider's permission sheet through a transparent activity. If the
217
+ user has permanently denied, the sheet does not appear — fall back to
218
+ `openHealthConnectSettings()`.
219
+
220
+ ### `readHealthConnectSteps(startIso, endIso)`
221
+
222
+ ISO-8601 instants. Aggregated per day across every source Health Connect knows
223
+ about, so it includes a paired watch, not just your app.
224
+
225
+ ```ts
226
+ { totalSteps: 82310, records: [ { date, steps, distance, calories, synced } ] }
227
+ ```
228
+
229
+ ### `writeHealthConnectSteps(date: string): Promise<boolean>`
230
+ ### `syncWithHealthConnect(): Promise<SyncEvent>`
231
+
232
+ Writes are idempotent: each record carries a stable `clientRecordId`
233
+ (`stp-steps-2026-09-06`), so re-syncing a day replaces the previous record
234
+ rather than stacking duplicates.
235
+
236
+ ### `openHealthConnectSettings(): Promise<boolean>`
237
+
238
+ ---
239
+
240
+ ## Sync
241
+
242
+ ### `syncNow(): Promise<SyncEvent[]>`
243
+
244
+ Runs Health Connect sync inline and queues the remote upload. The remote result
245
+ arrives later on the `syncCompleted` event, since WorkManager waits for a
246
+ network.
247
+
248
+ ### `getPendingSyncCount(): Promise<number>`
249
+
250
+ Days written locally but not yet mirrored. Non-zero offline is normal.
251
+
252
+ ---
253
+
254
+ ## Events
255
+
256
+ ```ts
257
+ const sub = StepTracker.addListener('stepsChanged', (data) => console.log(data.steps));
258
+ sub.remove();
259
+
260
+ StepTracker.removeListener('goalReached'); // all listeners for one event
261
+ StepTracker.removeListener(); // everything
262
+ ```
263
+
264
+ | Event | Payload |
265
+ |---|---|
266
+ | `stepsChanged` | `StepSnapshot`. Throttled by `eventThrottleMs` (default 500 ms). |
267
+ | `goalReached` | `{ type: 'daily' \| 'weekly' \| 'monthly', goal, steps, date, timestamp }`. Fires at most once per period. |
268
+ | `goalProgressChanged` | `{ type, goal, steps, progress, date }`. Fires when the whole-percent bucket changes. |
269
+ | `trackingStateChanged` | `{ state, source, reason }`. `reason` is `'boot'`, `'paused'`, `'no_sensor'`, etc. |
270
+ | `dayChanged` | `{ previousDate, currentDate, previousDaySteps }`. Refetch your stats here. |
271
+ | `syncCompleted` | `{ target: 'health_connect' \| 'remote', syncedRecords, failedRecords, success, error? }` |
272
+ | `error` | `{ code, message }`. Emitted from the service, where there is no promise to reject. |
273
+
274
+ Events fire only while a React instance is alive. The service keeps counting and
275
+ writing to the database regardless — on resume, call `getTodaySteps()` rather
276
+ than replaying missed events. `useStepTracker` already does this.
277
+
278
+ ---
279
+
280
+ ## Config
281
+
282
+ | Key | Default | Notes |
283
+ |---|---|---|
284
+ | `height` | 170 | cm, drives stride length |
285
+ | `weight` | 70 | kg, drives calories |
286
+ | `strideLength` | derived | metres; overrides the height-based estimate |
287
+ | `sex` | `'unspecified'` | picks the stride coefficient only |
288
+ | `dailyGoal` | 10000 | |
289
+ | `weeklyGoal` | `dailyGoal × 7` | |
290
+ | `monthlyGoal` | `dailyGoal × 30` | |
291
+ | `calorieCoefficient` | 0.57 | kcal per kg per km |
292
+ | `historyRetentionDays` | 35 | set 31 for a one-month window |
293
+ | `notificationTitle` / `notificationText` | built-in | tokens: `{steps}` `{distance}` `{calories}` `{percent}` `{goal}` |
294
+ | `notificationIcon` | bundled | drawable name in your app |
295
+ | `notificationChannelName` | "Step tracking" | shown in Android settings |
296
+ | `notificationActions` | `true` | Pause/Resume/Open buttons |
297
+ | `notificationThrottleMs` | 1000 | minimum ms between notification redraws |
298
+ | `eventThrottleMs` | 500 | minimum ms between `stepsChanged` events |
299
+ | `persistEveryNSteps` | 10 | database flush cadence |
300
+ | `healthConnectEnabled` | `true` | |
301
+ | `healthConnectSyncIntervalMinutes` | 30 | clamped to WorkManager's 15-minute floor; 0 disables |
302
+ | `remoteSyncUrl` | — | optional HTTPS endpoint for unsynced days |
303
+ | `remoteSyncHeaders` | `{}` | e.g. auth headers |
304
+ | `autoStartOnBoot` | `true` | |
305
+
306
+ ---
307
+
308
+ ## Hooks
309
+
310
+ ### `useStepTracker(options)`
311
+
312
+ ```tsx
313
+ const {
314
+ snapshot, state, ready, error,
315
+ start, pause, resume, stop, refresh, requestPermissions,
316
+ } = useStepTracker({ dailyGoal: 10000, autoStart: true, onGoalReached: (e) => {} });
317
+ ```
318
+
319
+ Initialises once, subscribes to `stepsChanged` and `trackingStateChanged`, and
320
+ re-reads the snapshot when the app returns to the foreground. Extra options:
321
+ `autoStart` (default false) and `refreshOnForeground` (default true).
322
+
323
+ ### `useStepStats(period, options?)`
324
+
325
+ ```tsx
326
+ const { stats, loading, error, reload } = useStepStats('week');
327
+ ```
328
+
329
+ `period` is `'week' | 'month' | 'year'`. Reloads automatically on `dayChanged`.
@@ -0,0 +1,203 @@
1
+ # Architecture
2
+
3
+ ```
4
+ ┌──────────────────────────┐
5
+ JS ────────────────│ StepTracker.ts (typed) │
6
+ └────────────┬─────────────┘
7
+ │ Turbo Module
8
+ ┌────────────▼─────────────┐
9
+ │ StepTrackerProModule │ thin: validate, delegate,
10
+ └────────────┬─────────────┘ convert
11
+
12
+ ┌──────────────────────────────▼───────────────────────────────┐
13
+ │ StepTrackerCore │
14
+ │ (process-wide singleton: config, engine, goals) │
15
+ └───┬──────────────┬──────────────┬───────────────┬────────────┘
16
+ │ │ │ │
17
+ ┌───────▼──────┐ ┌─────▼──────┐ ┌─────▼───────┐ ┌─────▼─────────┐
18
+ │ StepCounter │ │StepRepo/ │ │HealthConnect│ │ StepEventBus │
19
+ │ Engine │ │ Room │ │ Manager │ │ │
20
+ └───────▲──────┘ └────────────┘ └─────────────┘ └───────────────┘
21
+
22
+ ┌───────┴────────────────┐ ┌──────────────┐
23
+ │ StepTrackerService │◄───────│ BootReceiver │
24
+ │ (foreground, health) │ └──────────────┘
25
+ └────────────────────────┘
26
+ ```
27
+
28
+ The module is the only piece that dies with JS. Everything below it keeps
29
+ running, which is why the module holds no state of its own.
30
+
31
+ ## Why a singleton
32
+
33
+ The service and the React module live in the same process. Sharing one
34
+ `StepTrackerCore` means the counter cannot fork: a `getTodaySteps()` call from
35
+ JS reads the exact same engine the sensor callback just wrote to. Anything
36
+ routed through IPC or broadcasts would drift.
37
+
38
+ ## The counting maths
39
+
40
+ `TYPE_STEP_COUNTER` reports steps since the device booted. The day total is:
41
+
42
+ ```
43
+ stepsToday = anchorSteps + (rawValue − anchorValue)
44
+ ```
45
+
46
+ `anchorValue` is the raw reading that corresponded to `anchorSteps`. The anchor
47
+ is re-pinned whenever the meaning of `rawValue` changes.
48
+
49
+ ### Reboot
50
+
51
+ The counter restarts at zero. Two signals have to agree before the steps since
52
+ boot are claimed:
53
+
54
+ 1. The approximate boot timestamp — `System.currentTimeMillis() −
55
+ SystemClock.elapsedRealtime()` — moved by more than 60 seconds.
56
+ 2. The reading itself went backwards, below `lastRawValue`.
57
+
58
+ The boot timestamp on its own is not enough, because it is derived from the wall
59
+ clock: a manual clock change, or an NTP correction on a phone whose RTC was
60
+ wrong at boot, moves it by hours without the counter restarting. Acting on that
61
+ alone would add the day's steps to themselves.
62
+
63
+ Once a real reboot is established:
64
+
65
+ - **Boot happened today.** The steps taken between boot and the first sample are
66
+ real and unclaimed, so `anchorValue = 0` and they are picked up.
67
+ - **Boot happened on a previous day.** Those steps straddle midnight and cannot
68
+ be attributed, so `anchorValue = rawValue` and they are dropped. Losing a few
69
+ steps beats moving yesterday's steps into today.
70
+
71
+ ### Counter reset without a reboot
72
+
73
+ Some OEM sensor HALs restart the counter when the last listener unregisters.
74
+ Detected as `rawValue` dropping below `lastRawValue`, the previous reading; the
75
+ anchor is re-pinned at the current reading with `anchorSteps = stepsToday`, so
76
+ the running total is preserved and nothing is double counted.
77
+
78
+ Comparing against `anchorValue` instead would miss it entirely for the whole of
79
+ any day the device booted on, because the anchor is `0` for that day and no
80
+ valid reading is below zero.
81
+
82
+ ### Timezone moving backwards
83
+
84
+ `rollDateIfNeeded()` fires on any change of the local date key, in either
85
+ direction. Travelling west across the date line moves it backwards: the day
86
+ being left is persisted but is not finished, and the day being adopted may
87
+ already have steps stored against it, so `StepTrackerCore` re-seeds the live
88
+ counter from the database instead of letting it report zero mid-day.
89
+
90
+ ### Midnight
91
+
92
+ `rollDateIfNeeded()` finalises the previous day into Room, emits `dayChanged`,
93
+ prunes past the retention window, pushes the closed day into Health Connect, and
94
+ re-pins with `anchorSteps = 0`.
95
+
96
+ Sensor batching is set to zero latency, so a sample delivered after midnight for
97
+ steps taken before it is not a practical concern. If you raise
98
+ `MAX_REPORT_LATENCY_US` to save battery, that trade-off comes back.
99
+
100
+ ### Process death
101
+
102
+ Counter state is written to SharedPreferences on every sensor batch
103
+ (`StepStateStore.writeCounterState`, one atomic edit). If the OS kills the
104
+ service, `START_STICKY` restarts it, `reconcile()` reloads the anchor, and the
105
+ first sample recovers everything the hardware counted while the process was
106
+ gone. There is no window in which steps are lost — that is the whole reason for
107
+ using `TYPE_STEP_COUNTER` instead of `TYPE_STEP_DETECTOR`.
108
+
109
+ A restart with a null intent is the OS bringing the service back, not a user
110
+ action, so it restores rather than starts: `setPaused()` ignores a no-op
111
+ transition, and a pause the user set before the process died survives it. Arming
112
+ the resume re-anchor on every start would discard exactly the steps this
113
+ paragraph promises to recover.
114
+
115
+ ### The detector fallback
116
+
117
+ `TYPE_STEP_DETECTOR` fires once per step and carries no cumulative value, so it
118
+ increments the total directly. On devices with no step counter, steps taken
119
+ while the process is dead are genuinely lost. `getDeviceCapabilities()` tells
120
+ you which sensor you are on.
121
+
122
+ ## Storage
123
+
124
+ Two Room tables:
125
+
126
+ - `step_history` — `id`, `date` (unique), `steps`, `distance`, `calories`,
127
+ `synced`, `createdAt`, `updatedAt`. Hot write path.
128
+ - `daily_summary` — `date` (PK), `totalSteps`, `totalDistance`,
129
+ `totalCalories`, `updatedAt`. Rolled-up mirror of the same values, written in
130
+ the same transaction as `step_history`.
131
+
132
+ `saveDay()` refuses to lower an existing day's count. A re-anchored counter can
133
+ briefly report fewer steps than were already committed; ignoring that write is
134
+ cheaper than reasoning about it later. Deliberate writes that must be allowed to
135
+ lower a day — `resetToday()` — go through `overwriteDay()` instead, otherwise
136
+ the old total would survive the reset and history would disagree with the live
137
+ counter permanently.
138
+
139
+ Health Connect and the remote endpoint track their progress in **separate**
140
+ columns (`synced` and `syncedRemote`). Sharing one flag let whichever sink ran
141
+ first hide the row from the other, so a day uploaded remotely would never reach
142
+ Health Connect.
143
+
144
+ Counter state deliberately lives in SharedPreferences, not Room: it is written
145
+ constantly and must be readable synchronously from `onStartCommand` before any
146
+ coroutine gets a chance to run. It also means a corrupt database costs you
147
+ history, never the live count.
148
+
149
+ Flush cadence is `persistEveryNSteps` (default 10) plus a forced flush on pause,
150
+ stop, task removal and destroy.
151
+
152
+ ## Foreground service
153
+
154
+ Declared `android:foregroundServiceType="health"`, which on Android 14+ requires
155
+ `FOREGROUND_SERVICE_HEALTH` and is passed to `startForeground`. Below 34 the
156
+ two-argument overload is used, so no unknown type reaches an older platform.
157
+
158
+ `startForeground` is called first thing in `onStartCommand`, before anything
159
+ that can throw, or the OS raises `ForegroundServiceDidNotStartInTimeException`.
160
+
161
+ `stopWithTask="false"` keeps the service alive when the user swipes the app
162
+ away — that is the whole point. `onTaskRemoved` just flushes.
163
+
164
+ Notification updates are throttled to `notificationThrottleMs` and skipped when
165
+ the step count has not changed, so a walk does not redraw the shade sixty times
166
+ a minute.
167
+
168
+ ## Events
169
+
170
+ `StepEventBus` is a plain in-process pub/sub. The module subscribes on
171
+ construction and unsubscribes on `invalidate()`. Emission is a no-op when no
172
+ React instance is active — the service does not care whether anyone is
173
+ listening, it keeps writing to storage either way.
174
+
175
+ `stepsChanged` is throttled by `eventThrottleMs`. `goalProgressChanged` fires
176
+ only when the whole-percent bucket moves. `goalReached` is keyed on the period's
177
+ start date, so it fires once per day/week/month and rearms by itself.
178
+
179
+ ## Sync
180
+
181
+ Three WorkManager jobs:
182
+
183
+ | Worker | Constraint | Period |
184
+ |---|---|---|
185
+ | `HealthConnectSyncWorker` | none | `healthConnectSyncIntervalMinutes`, floor 15 |
186
+ | `RemoteSyncWorker` | network connected | 1 hour, exponential backoff |
187
+ | `RetentionWorker` | none | daily |
188
+
189
+ Health Connect sync has no network constraint because Health Connect is entirely
190
+ on-device. Offline sync is still sync. Only the optional `remoteSyncUrl` upload
191
+ waits for connectivity, and nothing else waits on it.
192
+
193
+ Today's row is written to Health Connect on every pass but left `synced = 0`,
194
+ because it is still moving. Closed days are marked synced once written.
195
+
196
+ ## Threading
197
+
198
+ - Sensor callbacks arrive on the sensor thread. Engine mutation is
199
+ `@Synchronized`.
200
+ - Database and Health Connect work runs on `Dispatchers.IO` from the core's
201
+ `SupervisorJob` scope, so one failed write cannot cancel the pipeline.
202
+ - Bridge conversion happens on whichever thread resolves the promise; the module
203
+ never touches the engine off-lock.