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.
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/android/build.gradle +119 -0
- package/android/consumer-rules.pro +13 -0
- package/android/gradle.properties +3 -0
- package/android/src/main/AndroidManifest.xml +78 -0
- package/android/src/main/java/com/steptrackerpro/StepTrackerProModule.kt +598 -0
- package/android/src/main/java/com/steptrackerpro/StepTrackerProPackage.kt +26 -0
- package/android/src/main/java/com/steptrackerpro/core/DateKeys.kt +90 -0
- package/android/src/main/java/com/steptrackerpro/core/GoalTracker.kt +66 -0
- package/android/src/main/java/com/steptrackerpro/core/MetricsCalculator.kt +48 -0
- package/android/src/main/java/com/steptrackerpro/core/Models.kt +73 -0
- package/android/src/main/java/com/steptrackerpro/core/StepCounterEngine.kt +338 -0
- package/android/src/main/java/com/steptrackerpro/core/StepStateStore.kt +137 -0
- package/android/src/main/java/com/steptrackerpro/core/StepTrackerConfig.kt +184 -0
- package/android/src/main/java/com/steptrackerpro/core/StepTrackerCore.kt +280 -0
- package/android/src/main/java/com/steptrackerpro/db/Entities.kt +67 -0
- package/android/src/main/java/com/steptrackerpro/db/StepDao.kt +118 -0
- package/android/src/main/java/com/steptrackerpro/db/StepDatabase.kt +60 -0
- package/android/src/main/java/com/steptrackerpro/db/StepRepository.kt +146 -0
- package/android/src/main/java/com/steptrackerpro/health/HealthConnectManager.kt +198 -0
- package/android/src/main/java/com/steptrackerpro/health/HealthPermissionActivity.kt +99 -0
- package/android/src/main/java/com/steptrackerpro/service/BootReceiver.kt +54 -0
- package/android/src/main/java/com/steptrackerpro/service/NotificationActionReceiver.kt +17 -0
- package/android/src/main/java/com/steptrackerpro/service/NotificationFactory.kt +170 -0
- package/android/src/main/java/com/steptrackerpro/service/ServiceCommands.kt +42 -0
- package/android/src/main/java/com/steptrackerpro/service/StepTrackerService.kt +409 -0
- package/android/src/main/java/com/steptrackerpro/sync/SyncScheduler.kt +102 -0
- package/android/src/main/java/com/steptrackerpro/sync/SyncWorkers.kt +175 -0
- package/android/src/main/java/com/steptrackerpro/util/BatteryOptimizationHelper.kt +94 -0
- package/android/src/main/java/com/steptrackerpro/util/Bridge.kt +149 -0
- package/android/src/main/java/com/steptrackerpro/util/PermissionHelper.kt +92 -0
- package/android/src/main/java/com/steptrackerpro/util/StepEventBus.kt +53 -0
- package/android/src/main/res/drawable/stp_ic_steps.xml +9 -0
- package/android/src/main/res/values/strings.xml +9 -0
- package/android/src/main/res/values/styles.xml +8 -0
- package/android/src/newarch/java/com/steptrackerpro/StepTrackerProSpec.kt +10 -0
- package/android/src/oldarch/java/com/steptrackerpro/StepTrackerProSpec.kt +60 -0
- package/docs/API.md +329 -0
- package/docs/ARCHITECTURE.md +203 -0
- package/docs/INSTALLATION.md +175 -0
- package/docs/PLAY_STORE_COMPLIANCE.md +122 -0
- package/docs/TESTING.md +312 -0
- package/docs/TROUBLESHOOTING.md +96 -0
- package/lib/commonjs/NativeStepTrackerPro.js +14 -0
- package/lib/commonjs/NativeStepTrackerPro.js.map +1 -0
- package/lib/commonjs/StepTracker.js +290 -0
- package/lib/commonjs/StepTracker.js.map +1 -0
- package/lib/commonjs/constants.js +30 -0
- package/lib/commonjs/constants.js.map +1 -0
- package/lib/commonjs/errors.js +24 -0
- package/lib/commonjs/errors.js.map +1 -0
- package/lib/commonjs/hooks/index.js +20 -0
- package/lib/commonjs/hooks/index.js.map +1 -0
- package/lib/commonjs/hooks/useStepStats.js +51 -0
- package/lib/commonjs/hooks/useStepStats.js.map +1 -0
- package/lib/commonjs/hooks/useStepTracker.js +134 -0
- package/lib/commonjs/hooks/useStepTracker.js.map +1 -0
- package/lib/commonjs/index.js +64 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/NativeStepTrackerPro.js +12 -0
- package/lib/module/NativeStepTrackerPro.js.map +1 -0
- package/lib/module/StepTracker.js +284 -0
- package/lib/module/StepTracker.js.map +1 -0
- package/lib/module/constants.js +26 -0
- package/lib/module/constants.js.map +1 -0
- package/lib/module/errors.js +18 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/hooks/index.js +5 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/useStepStats.js +46 -0
- package/lib/module/hooks/useStepStats.js.map +1 -0
- package/lib/module/hooks/useStepTracker.js +129 -0
- package/lib/module/hooks/useStepTracker.js.map +1 -0
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/NativeStepTrackerPro.d.ts +50 -0
- package/lib/typescript/NativeStepTrackerPro.d.ts.map +1 -0
- package/lib/typescript/StepTracker.d.ts +76 -0
- package/lib/typescript/StepTracker.d.ts.map +1 -0
- package/lib/typescript/constants.d.ts +10 -0
- package/lib/typescript/constants.d.ts.map +1 -0
- package/lib/typescript/errors.d.ts +8 -0
- package/lib/typescript/errors.d.ts.map +1 -0
- package/lib/typescript/hooks/index.d.ts +5 -0
- package/lib/typescript/hooks/index.d.ts.map +1 -0
- package/lib/typescript/hooks/useStepStats.d.ts +11 -0
- package/lib/typescript/hooks/useStepStats.d.ts.map +1 -0
- package/lib/typescript/hooks/useStepTracker.d.ts +26 -0
- package/lib/typescript/hooks/useStepTracker.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +7 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +183 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/package.json +79 -0
- package/src/NativeStepTrackerPro.ts +66 -0
- package/src/StepTracker.ts +410 -0
- package/src/constants.ts +43 -0
- package/src/errors.ts +31 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useStepStats.ts +55 -0
- package/src/hooks/useStepTracker.ts +167 -0
- package/src/index.ts +27 -0
- 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.
|