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,90 @@
1
+ package com.steptrackerpro.core
2
+
3
+ import java.time.DayOfWeek
4
+ import java.time.Instant
5
+ import java.time.LocalDate
6
+ import java.time.ZoneId
7
+ import java.time.ZonedDateTime
8
+ import java.time.format.DateTimeFormatter
9
+ import java.time.temporal.TemporalAdjusters
10
+
11
+ /**
12
+ * All persistence keys off dates as `yyyy-MM-dd` in the device's current zone.
13
+ * Everything that needs a wall-clock day goes through here so a timezone change
14
+ * is handled in exactly one place.
15
+ */
16
+ object DateKeys {
17
+
18
+ private val FORMAT: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
19
+
20
+ fun zone(): ZoneId = ZoneId.systemDefault()
21
+
22
+ fun today(): String = LocalDate.now(zone()).format(FORMAT)
23
+
24
+ fun yesterday(): String = LocalDate.now(zone()).minusDays(1).format(FORMAT)
25
+
26
+ fun of(epochMillis: Long): String =
27
+ Instant.ofEpochMilli(epochMillis).atZone(zone()).toLocalDate().format(FORMAT)
28
+
29
+ fun parse(key: String): LocalDate = LocalDate.parse(key, FORMAT)
30
+
31
+ fun format(date: LocalDate): String = date.format(FORMAT)
32
+
33
+ fun startOfDayMillis(key: String): Long =
34
+ parse(key).atStartOfDay(zone()).toInstant().toEpochMilli()
35
+
36
+ fun endOfDayMillis(key: String): Long =
37
+ parse(key).plusDays(1).atStartOfDay(zone()).toInstant().toEpochMilli() - 1
38
+
39
+ fun startOfDayInstant(key: String): Instant =
40
+ parse(key).atStartOfDay(zone()).toInstant()
41
+
42
+ fun endOfDayInstant(key: String): Instant =
43
+ parse(key).plusDays(1).atStartOfDay(zone()).toInstant()
44
+
45
+ /** Every date from start to end, inclusive, ascending. */
46
+ fun rangeOf(start: String, end: String): List<String> {
47
+ val from = parse(start)
48
+ val to = parse(end)
49
+ if (to.isBefore(from)) return emptyList()
50
+ val out = ArrayList<String>()
51
+ var cursor = from
52
+ while (!cursor.isAfter(to)) {
53
+ out.add(format(cursor))
54
+ cursor = cursor.plusDays(1)
55
+ }
56
+ return out
57
+ }
58
+
59
+ fun daysBetween(start: String, end: String): Int =
60
+ (parse(end).toEpochDay() - parse(start).toEpochDay()).toInt() + 1
61
+
62
+ // ---- window helpers -------------------------------------------------
63
+
64
+ /** Monday-anchored ISO week. `offset` 1 means last week. */
65
+ fun calendarWeek(offset: Int = 0): Pair<String, String> {
66
+ val base = LocalDate.now(zone()).minusWeeks(offset.toLong())
67
+ val start = base.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
68
+ return format(start) to format(start.plusDays(6))
69
+ }
70
+
71
+ fun calendarMonth(offset: Int = 0): Pair<String, String> {
72
+ val base = LocalDate.now(zone()).withDayOfMonth(1).minusMonths(offset.toLong())
73
+ return format(base) to format(base.with(TemporalAdjusters.lastDayOfMonth()))
74
+ }
75
+
76
+ fun calendarYear(offset: Int = 0): Pair<String, String> {
77
+ val base = LocalDate.now(zone()).withDayOfYear(1).minusYears(offset.toLong())
78
+ return format(base) to format(base.with(TemporalAdjusters.lastDayOfYear()))
79
+ }
80
+
81
+ /** Last `days` days ending today, inclusive. */
82
+ fun rolling(days: Int): Pair<String, String> {
83
+ val end = LocalDate.now(zone())
84
+ return format(end.minusDays((days - 1).toLong())) to format(end)
85
+ }
86
+
87
+ fun minusDays(key: String, days: Int): String = format(parse(key).minusDays(days.toLong()))
88
+
89
+ fun nowZoned(): ZonedDateTime = ZonedDateTime.now(zone())
90
+ }
@@ -0,0 +1,66 @@
1
+ package com.steptrackerpro.core
2
+
3
+ import android.content.Context
4
+
5
+ /**
6
+ * Fires each goal at most once per period. The "already fired" marker is the
7
+ * period's start date, so a new week or month rearms automatically and a
8
+ * process restart cannot double fire.
9
+ */
10
+ class GoalTracker(context: Context) {
11
+
12
+ private val prefs = context.applicationContext
13
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
14
+
15
+ data class Reached(val type: String, val goal: Int, val steps: Int, val periodKey: String)
16
+
17
+ fun checkDaily(date: String, steps: Int, goal: Int): Reached? =
18
+ check(TYPE_DAILY, date, steps, goal)
19
+
20
+ fun checkWeekly(steps: Int, goal: Int): Reached? =
21
+ check(TYPE_WEEKLY, DateKeys.calendarWeek().first, steps, goal)
22
+
23
+ fun checkMonthly(steps: Int, goal: Int): Reached? =
24
+ check(TYPE_MONTHLY, DateKeys.calendarMonth().first, steps, goal)
25
+
26
+ /** Emits progress only when the whole-percent bucket changes. */
27
+ fun progressBucketChanged(type: String, percent: Int): Boolean {
28
+ val key = "$KEY_BUCKET_PREFIX$type"
29
+ if (prefs.getInt(key, -1) == percent) return false
30
+ prefs.edit().putInt(key, percent).apply()
31
+ return true
32
+ }
33
+
34
+ /** True when this goal has already been reported for the given period. */
35
+ fun alreadyFired(type: String, periodKey: String): Boolean =
36
+ prefs.getString("$KEY_FIRED_PREFIX$type", null) == periodKey
37
+
38
+ /**
39
+ * Rearms today's goal. Deliberately does not clear the weekly and monthly
40
+ * markers: those are period-scoped, and wiping them when a single day is
41
+ * reset lets both fire a second time inside the same week or month.
42
+ */
43
+ fun reset() {
44
+ prefs.edit()
45
+ .remove("$KEY_FIRED_PREFIX$TYPE_DAILY")
46
+ .remove("$KEY_BUCKET_PREFIX$TYPE_DAILY")
47
+ .apply()
48
+ }
49
+
50
+ private fun check(type: String, periodKey: String, steps: Int, goal: Int): Reached? {
51
+ if (goal <= 0 || steps < goal) return null
52
+ val key = "$KEY_FIRED_PREFIX$type"
53
+ if (prefs.getString(key, null) == periodKey) return null
54
+ prefs.edit().putString(key, periodKey).apply()
55
+ return Reached(type, goal, steps, periodKey)
56
+ }
57
+
58
+ companion object {
59
+ const val PREFS_NAME = "StepTrackerProGoals"
60
+ const val TYPE_DAILY = "daily"
61
+ const val TYPE_WEEKLY = "weekly"
62
+ const val TYPE_MONTHLY = "monthly"
63
+ private const val KEY_FIRED_PREFIX = "fired_"
64
+ private const val KEY_BUCKET_PREFIX = "bucket_"
65
+ }
66
+ }
@@ -0,0 +1,48 @@
1
+ package com.steptrackerpro.core
2
+
3
+ import kotlin.math.max
4
+ import kotlin.math.min
5
+ import kotlin.math.roundToInt
6
+
7
+ /**
8
+ * Distance and calorie estimation.
9
+ *
10
+ * Distance = steps × stride length, where stride length defaults to
11
+ * height × 0.415 (male) / 0.413 (female) / 0.414 (unspecified).
12
+ *
13
+ * Calories use the standard walking approximation
14
+ * `kcal = coefficient × bodyMassKg × distanceKm`, with a default coefficient
15
+ * of 0.57 kcal/kg/km. This is gross expenditure at a typical walking pace; it
16
+ * is an estimate, not a measurement, and it does not model incline or speed.
17
+ */
18
+ class MetricsCalculator(@Volatile var config: StepTrackerConfig) {
19
+
20
+ /** Metres. */
21
+ fun distance(steps: Int): Double = max(0, steps) * effectiveStride()
22
+
23
+ /** Kilocalories. */
24
+ fun calories(steps: Int): Double {
25
+ val km = distance(steps) / 1000.0
26
+ return config.calorieCoefficient * config.weightKg * km
27
+ }
28
+
29
+ fun totals(date: String, steps: Int, synced: Boolean = false): DayTotals =
30
+ DayTotals(date, steps, distance(steps), calories(steps), synced)
31
+
32
+ fun goalProgress(steps: Int, goal: Int): Double {
33
+ if (goal <= 0) return 0.0
34
+ return min(1.0, max(0.0, steps.toDouble() / goal.toDouble()))
35
+ }
36
+
37
+ fun goalPercent(steps: Int, goal: Int): Int = (goalProgress(steps, goal) * 100).roundToInt()
38
+
39
+ private fun effectiveStride(): Double {
40
+ if (config.strideLengthM > 0.0) return config.strideLengthM
41
+ val coefficient = when (config.sex) {
42
+ "male" -> 0.415
43
+ "female" -> 0.413
44
+ else -> 0.414
45
+ }
46
+ return (config.heightCm * coefficient) / 100.0
47
+ }
48
+ }
@@ -0,0 +1,73 @@
1
+ package com.steptrackerpro.core
2
+
3
+ /** Mirrors the `TrackingState` union in types.ts. */
4
+ enum class TrackingState(val jsValue: String) {
5
+ IDLE("idle"),
6
+ RUNNING("running"),
7
+ PAUSED("paused"),
8
+ STOPPED("stopped"),
9
+ UNSUPPORTED("unsupported");
10
+
11
+ companion object {
12
+ fun from(value: String?): TrackingState =
13
+ entries.firstOrNull { it.jsValue == value } ?: IDLE
14
+ }
15
+ }
16
+
17
+ enum class SensorSource(val jsValue: String) {
18
+ STEP_COUNTER("step_counter"),
19
+ STEP_DETECTOR("step_detector"),
20
+ NONE("none");
21
+
22
+ companion object {
23
+ fun from(value: String?): SensorSource =
24
+ entries.firstOrNull { it.jsValue == value } ?: NONE
25
+ }
26
+ }
27
+
28
+ /** A live view of today. `distance` is metres, `calories` is kcal. */
29
+ data class StepSnapshot(
30
+ val date: String,
31
+ val steps: Int,
32
+ val distance: Double,
33
+ val calories: Double,
34
+ val dailyGoal: Int,
35
+ val goalProgress: Double,
36
+ val goalReached: Boolean,
37
+ val state: TrackingState,
38
+ val source: SensorSource,
39
+ val timestamp: Long
40
+ )
41
+
42
+ /**
43
+ * Where a day's totals can be mirrored to. Each sink tracks its own progress:
44
+ * one shared flag lets whichever sink runs first hide the row from the other,
45
+ * so a day uploaded remotely would never reach Health Connect and vice versa.
46
+ */
47
+ enum class SyncTarget { HEALTH_CONNECT, REMOTE }
48
+
49
+ /** One finalised day, as stored in Room and returned by history queries. */
50
+ data class DayTotals(
51
+ val date: String,
52
+ val steps: Int,
53
+ val distance: Double,
54
+ val calories: Double,
55
+ /** Mirrored into Health Connect. */
56
+ val synced: Boolean = false,
57
+ /** Uploaded to `remoteSyncUrl`. Always false when no endpoint is configured. */
58
+ val syncedRemote: Boolean = false
59
+ )
60
+
61
+ data class RangeStats(
62
+ val startDate: String,
63
+ val endDate: String,
64
+ val totalSteps: Int,
65
+ val totalDistance: Double,
66
+ val totalCalories: Double,
67
+ val averageSteps: Int,
68
+ val activeDays: Int,
69
+ val bestDay: DayTotals?,
70
+ val days: List<DayTotals>,
71
+ val goal: Int?,
72
+ val goalProgress: Double?
73
+ )
@@ -0,0 +1,338 @@
1
+ package com.steptrackerpro.core
2
+
3
+ import android.os.SystemClock
4
+ import kotlin.math.abs
5
+ import kotlin.math.roundToInt
6
+
7
+ /**
8
+ * Turns raw sensor readings into a per-day step total that survives reboots,
9
+ * process death and midnight.
10
+ *
11
+ * TYPE_STEP_COUNTER reports steps since the device booted, so the total for a
12
+ * day is:
13
+ *
14
+ * stepsToday = anchorSteps + (rawValue - anchorValue)
15
+ *
16
+ * The anchor is re-pinned whenever the meaning of `rawValue` changes:
17
+ *
18
+ * - **Reboot.** The counter restarts at zero. The boot timestamp
19
+ * (`wallClock - elapsedRealtime`) is only a *hint* that this happened: it is
20
+ * derived from the wall clock, so a manual clock change or an NTP correction
21
+ * moves it without the counter restarting, and claiming the steps since boot
22
+ * then would add the day's steps to themselves. Acting on it needs corroborating
23
+ * proof, of which there are two, either of which is enough: the counter
24
+ * reading went backwards, or `elapsedRealtime` did. The latter cannot be set
25
+ * by the user and restarts at zero on every boot, so it still catches a
26
+ * reboot whose first post-boot reading happens to exceed the last one seen.
27
+ *
28
+ * Once a reboot is established: if it happened today the steps taken since
29
+ * boot are real and unclaimed, so the anchor goes to zero and they are
30
+ * picked up. If it was on a previous day they cannot be split across
31
+ * midnight, so the anchor is pinned at the current reading and they are
32
+ * dropped rather than misattributed.
33
+ * - **Counter reset without reboot.** Some OEM sensor HALs restart the counter
34
+ * when the last listener unregisters. Detected as `rawValue` dropping below
35
+ * the last reading we saw; the anchor is re-pinned at the current reading so
36
+ * the running total is preserved and nothing is double counted. Comparing
37
+ * against the anchor alone is not enough, because the anchor is zero for the
38
+ * whole of any day the device booted on.
39
+ * - **Midnight.** The previous day is finalised through [onDayRollover] and the
40
+ * anchor is re-pinned with `anchorSteps = 0`.
41
+ * - **Resume after pause.**
42
+ *
43
+ * All mutation happens under the instance lock; sensor callbacks arrive on the
44
+ * service's sensor thread while JS reads arrive on the native modules thread.
45
+ */
46
+ class StepCounterEngine(
47
+ private val state: StepStateStore,
48
+ private val metrics: MetricsCalculator,
49
+ /** Overridable so the reboot path can be exercised in tests. */
50
+ private val elapsedProvider: () -> Long = { SystemClock.elapsedRealtime() },
51
+ /** Overridable so the reboot path can be exercised in tests. */
52
+ private val bootIdProvider: () -> Long = { currentBootId() }
53
+ ) {
54
+
55
+ /** Invoked with the finalised previous day when the date rolls over. */
56
+ var onDayRollover: ((DayTotals, String) -> Unit)? = null
57
+
58
+ @Volatile
59
+ var paused: Boolean = false
60
+ private set
61
+
62
+ @Volatile
63
+ private var pendingCommit: Int = 0
64
+
65
+ /**
66
+ * Set on resume. The next sample pins the anchor at the current reading so
67
+ * steps taken while paused are discarded instead of landing in the total.
68
+ * Distinct from the first-sample branch, which may legitimately claim
69
+ * everything since boot.
70
+ */
71
+ @Volatile
72
+ private var reanchorOnNextSample: Boolean = false
73
+
74
+ /**
75
+ * Reconciles persisted state against the current boot and date. Call this
76
+ * from `onStartCommand` before registering any listener.
77
+ */
78
+ @Synchronized
79
+ fun reconcile(): StepSnapshot {
80
+ rollDateIfNeeded()
81
+ val currentBoot = bootIdProvider()
82
+ if (bootIdMoved(currentBoot)) {
83
+ // The counter may be about to restart at zero. Wait for the first
84
+ // sample to decide the anchor - only that sample can tell a real
85
+ // reboot from a wall-clock correction - but remember the new boot.
86
+ // `lastRawValue` is deliberately preserved: it is the evidence.
87
+ state.anchorValue = -1f
88
+ state.bootId = currentBoot
89
+ }
90
+ return snapshot()
91
+ }
92
+
93
+ /**
94
+ * @param rawValue cumulative steps since boot from TYPE_STEP_COUNTER.
95
+ * @return a snapshot when the total changed, otherwise null.
96
+ */
97
+ @Synchronized
98
+ fun onCounterSample(rawValue: Float, eventAtMillis: Long): StepSnapshot? {
99
+ if (rawValue < 0f || rawValue.isNaN()) return null
100
+ rollDateIfNeeded()
101
+
102
+ val currentBoot = bootIdProvider()
103
+ val currentElapsed = elapsedProvider()
104
+ val lastRaw = state.lastRawValue
105
+ val hasLastRaw = lastRaw >= 0f
106
+ // The counter is cumulative and monotonic within a boot, so a reading
107
+ // below the last one we saw proves it restarted.
108
+ val counterWentBackwards = hasLastRaw && rawValue < lastRaw
109
+ // So does uptime going backwards, and that one still fires when the
110
+ // first post-boot reading happens to exceed the last pre-reboot one -
111
+ // two reboots in quick succession, say.
112
+ val lastElapsed = state.lastElapsedRealtime
113
+ val elapsedWentBackwards = lastElapsed > 0L && currentElapsed < lastElapsed
114
+
115
+ var anchorValue = state.anchorValue
116
+ var anchorSteps = state.anchorSteps
117
+
118
+ if (state.anchorValue < 0f || bootIdMoved(currentBoot)) {
119
+ val bootDate = DateKeys.of(currentBoot)
120
+ // Steps accumulated between boot and this first sample are only
121
+ // claimable when the counter really did restart - proven by the
122
+ // reading going backwards, or by there being no previous reading at
123
+ // all - and the boot itself happened on the active day. Without that
124
+ // the reading still contains everything already in stepsToday, and
125
+ // claiming it would add the day's steps to themselves.
126
+ val counterRestarted = !hasLastRaw || counterWentBackwards || elapsedWentBackwards
127
+ anchorValue = if (counterRestarted && bootDate == state.activeDate) 0f else rawValue
128
+ anchorSteps = state.stepsToday
129
+ } else if (reanchorOnNextSample || counterWentBackwards || elapsedWentBackwards ||
130
+ rawValue < anchorValue
131
+ ) {
132
+ // Resume, or a sensor restart without a reboot: re-pin at the
133
+ // current reading and keep the total.
134
+ anchorValue = rawValue
135
+ anchorSteps = state.stepsToday
136
+ }
137
+ reanchorOnNextSample = false
138
+
139
+ val total = anchorSteps + (rawValue - anchorValue).roundToInt()
140
+ if (total < 0) return null
141
+
142
+ val previous = state.stepsToday
143
+ if (paused) {
144
+ // Absorb the delta into the anchor so paused steps never land in
145
+ // the total, and the counter picks straight back up on resume.
146
+ state.writeCounterState(
147
+ bootId = currentBoot,
148
+ anchorValue = rawValue,
149
+ anchorSteps = previous,
150
+ lastRaw = rawValue,
151
+ activeDate = state.activeDate,
152
+ stepsToday = previous,
153
+ lastEventAt = eventAtMillis,
154
+ lastElapsed = currentElapsed
155
+ )
156
+ return null
157
+ }
158
+
159
+ state.writeCounterState(
160
+ bootId = currentBoot,
161
+ anchorValue = anchorValue,
162
+ anchorSteps = anchorSteps,
163
+ lastRaw = rawValue,
164
+ activeDate = state.activeDate,
165
+ stepsToday = total,
166
+ lastEventAt = eventAtMillis,
167
+ lastElapsed = currentElapsed
168
+ )
169
+
170
+ if (total == previous) return null
171
+ pendingCommit += (total - previous)
172
+ return snapshot()
173
+ }
174
+
175
+ /**
176
+ * TYPE_STEP_DETECTOR fires once per step and carries no cumulative value,
177
+ * so the running total is incremented directly. Only used on devices with
178
+ * no step counter, where steps are lost while the process is dead.
179
+ */
180
+ @Synchronized
181
+ fun onDetectorSample(steps: Int, eventAtMillis: Long): StepSnapshot? {
182
+ if (steps <= 0) return null
183
+ rollDateIfNeeded()
184
+ if (paused) return null
185
+
186
+ val total = state.stepsToday + steps
187
+ // The counter fields are left exactly as they were. Blanking them here
188
+ // would send the first counter sample on a dual-sensor device that fell
189
+ // back to the detector down the claim-everything-since-boot path, on top
190
+ // of the steps the detector already counted.
191
+ state.writeCounterState(
192
+ bootId = bootIdProvider(),
193
+ anchorValue = state.anchorValue,
194
+ anchorSteps = state.anchorSteps,
195
+ lastRaw = state.lastRawValue,
196
+ activeDate = state.activeDate,
197
+ stepsToday = total,
198
+ lastEventAt = eventAtMillis,
199
+ lastElapsed = elapsedProvider()
200
+ )
201
+ // The counter total no longer matches the anchor arithmetic, so make the
202
+ // next counter sample re-pin instead of recomputing from a stale anchor.
203
+ reanchorOnNextSample = true
204
+ pendingCommit += steps
205
+ return snapshot()
206
+ }
207
+
208
+ @Synchronized
209
+ fun setPaused(value: Boolean) {
210
+ // Only a real transition arms the re-anchor. Arming it on every
211
+ // startTracking() - including the START_STICKY restart after a process
212
+ // kill - would throw away everything the hardware counted while the
213
+ // process was gone, which is the entire point of TYPE_STEP_COUNTER.
214
+ if (paused == value) return
215
+ paused = value
216
+ // Skip the paused window on the next sample. Clearing the anchor here
217
+ // instead would send the next sample down the first-sample branch,
218
+ // which can claim every step since boot.
219
+ if (!value) reanchorOnNextSample = true
220
+ }
221
+
222
+ /**
223
+ * True once enough steps have accumulated to justify a database write.
224
+ * The threshold floors at 1: at 0 the comparison is true even with nothing
225
+ * pending, which turns every sample into a database write and a pair of
226
+ * aggregate queries.
227
+ */
228
+ @Synchronized
229
+ fun shouldCommit(threshold: Int): Boolean =
230
+ pendingCommit > 0 && pendingCommit >= threshold.coerceAtLeast(1)
231
+
232
+ @Synchronized
233
+ fun consumeCommit(): DayTotals {
234
+ pendingCommit = 0
235
+ return metrics.totals(state.activeDate, state.stepsToday)
236
+ }
237
+
238
+ @Synchronized
239
+ fun snapshot(): StepSnapshot {
240
+ val steps = state.stepsToday
241
+ val goal = metrics.config.dailyGoal
242
+ return StepSnapshot(
243
+ date = state.activeDate,
244
+ steps = steps,
245
+ distance = metrics.distance(steps),
246
+ calories = metrics.calories(steps),
247
+ dailyGoal = goal,
248
+ goalProgress = metrics.goalProgress(steps, goal),
249
+ goalReached = goal > 0 && steps >= goal,
250
+ state = state.trackingState,
251
+ source = state.source,
252
+ timestamp = state.lastEventAt.takeIf { it > 0 } ?: System.currentTimeMillis()
253
+ )
254
+ }
255
+
256
+ /** Zeroes today without touching stored history. */
257
+ @Synchronized
258
+ fun resetToday() {
259
+ pendingCommit = 0
260
+ state.writeCounterState(
261
+ bootId = bootIdProvider(),
262
+ anchorValue = -1f,
263
+ anchorSteps = 0,
264
+ // Keep the last reading. Blanking it would make the next sample
265
+ // look like a fresh counter and replay every step since boot,
266
+ // undoing the reset within one sample on any device booted today.
267
+ lastRaw = state.lastRawValue,
268
+ activeDate = DateKeys.today(),
269
+ stepsToday = 0,
270
+ lastEventAt = System.currentTimeMillis(),
271
+ lastElapsed = elapsedProvider()
272
+ )
273
+ }
274
+
275
+ /**
276
+ * Restores the live total for the active day from storage. Used when the
277
+ * local date moves backwards - travelling west across the date line - and
278
+ * the adopted day already has steps recorded against it.
279
+ */
280
+ @Synchronized
281
+ fun seedActiveDay(date: String, steps: Int) {
282
+ if (date != state.activeDate || steps <= state.stepsToday) return
283
+ state.writeCounterState(
284
+ bootId = bootIdProvider(),
285
+ anchorValue = -1f,
286
+ anchorSteps = 0,
287
+ lastRaw = state.lastRawValue,
288
+ activeDate = state.activeDate,
289
+ stepsToday = steps,
290
+ lastEventAt = System.currentTimeMillis(),
291
+ lastElapsed = elapsedProvider()
292
+ )
293
+ }
294
+
295
+ /** Finalises the previous day and starts a fresh one. */
296
+ private fun rollDateIfNeeded() {
297
+ val today = DateKeys.today()
298
+ val active = state.activeDate
299
+ if (today == active) return
300
+
301
+ // The date can also move *backwards*, when the zone moves west across
302
+ // the date line. The day being left is then still in the future and is
303
+ // not finished, and the day being adopted may already have steps against
304
+ // it, so [onDayRollover]'s receiver compares the two dates and re-seeds
305
+ // through [seedActiveDay] rather than letting the counter report zero
306
+ // in the middle of a day the user has already been walking through.
307
+ val closing = metrics.totals(active, state.stepsToday)
308
+ pendingCommit = 0
309
+ state.writeCounterState(
310
+ bootId = bootIdProvider(),
311
+ anchorValue = -1f,
312
+ anchorSteps = 0,
313
+ lastRaw = state.lastRawValue,
314
+ activeDate = today,
315
+ stepsToday = 0,
316
+ lastEventAt = System.currentTimeMillis(),
317
+ lastElapsed = elapsedProvider()
318
+ )
319
+ onDayRollover?.invoke(closing, today)
320
+ }
321
+
322
+ /**
323
+ * True when the derived boot timestamp moved further than clock jitter.
324
+ * This is a *hint* that the device rebooted, never proof: the value is
325
+ * `wallClock - elapsedRealtime`, so any wall-clock correction moves it.
326
+ * Callers must corroborate it with the counter reading itself.
327
+ */
328
+ private fun bootIdMoved(currentBoot: Long): Boolean {
329
+ val stored = state.bootId
330
+ if (stored == 0L) return false
331
+ return abs(currentBoot - stored) > StepStateStore.BOOT_DRIFT_TOLERANCE_MS
332
+ }
333
+
334
+ companion object {
335
+ /** Approximate epoch millis at which the device booted. */
336
+ fun currentBootId(): Long = System.currentTimeMillis() - SystemClock.elapsedRealtime()
337
+ }
338
+ }