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,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
|
+
}
|