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,137 @@
1
+ package com.steptrackerpro.core
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+
6
+ /**
7
+ * Durable counter state. Kept in SharedPreferences rather than Room because it
8
+ * is written on nearly every sensor batch and must be readable synchronously
9
+ * from `onStartCommand` before any coroutine has a chance to run.
10
+ *
11
+ * Inspect on a debug build with:
12
+ * adb shell run-as <applicationId> cat shared_prefs/StepTrackerProState.xml
13
+ */
14
+ class StepStateStore(context: Context) {
15
+
16
+ private val prefs: SharedPreferences = context.applicationContext
17
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
18
+
19
+ /**
20
+ * Approximate boot timestamp. A change larger than [BOOT_DRIFT_TOLERANCE_MS]
21
+ * means the device rebooted and TYPE_STEP_COUNTER restarted from zero.
22
+ */
23
+ var bootId: Long
24
+ get() = prefs.getLong(KEY_BOOT_ID, 0L)
25
+ set(value) = prefs.edit().putLong(KEY_BOOT_ID, value).apply()
26
+
27
+ /** Raw sensor reading that corresponds to [anchorSteps]. */
28
+ var anchorValue: Float
29
+ get() = prefs.getFloat(KEY_ANCHOR_VALUE, -1f)
30
+ set(value) = prefs.edit().putFloat(KEY_ANCHOR_VALUE, value).apply()
31
+
32
+ /** Steps already attributed to [activeDate] at the anchor point. */
33
+ var anchorSteps: Int
34
+ get() = prefs.getInt(KEY_ANCHOR_STEPS, 0)
35
+ set(value) = prefs.edit().putInt(KEY_ANCHOR_STEPS, value).apply()
36
+
37
+ var lastRawValue: Float
38
+ get() = prefs.getFloat(KEY_LAST_RAW, -1f)
39
+ set(value) = prefs.edit().putFloat(KEY_LAST_RAW, value).apply()
40
+
41
+ /**
42
+ * `SystemClock.elapsedRealtime()` at the last sample. Unlike the wall clock
43
+ * it cannot be set by the user and it restarts at zero on every boot, so a
44
+ * value below the stored one is *proof* of a reboot rather than the hint
45
+ * [bootId] gives.
46
+ */
47
+ var lastElapsedRealtime: Long
48
+ get() = prefs.getLong(KEY_LAST_ELAPSED, 0L)
49
+ set(value) = prefs.edit().putLong(KEY_LAST_ELAPSED, value).apply()
50
+
51
+ var activeDate: String
52
+ get() = prefs.getString(KEY_ACTIVE_DATE, null) ?: DateKeys.today()
53
+ set(value) = prefs.edit().putString(KEY_ACTIVE_DATE, value).apply()
54
+
55
+ /** Cached total so `getTodaySteps()` is instant even with the service dead. */
56
+ var stepsToday: Int
57
+ get() = prefs.getInt(KEY_STEPS_TODAY, 0)
58
+ set(value) = prefs.edit().putInt(KEY_STEPS_TODAY, value).apply()
59
+
60
+ var trackingState: TrackingState
61
+ get() = TrackingState.from(prefs.getString(KEY_STATE, null))
62
+ set(value) = prefs.edit().putString(KEY_STATE, value.jsValue).apply()
63
+
64
+ var source: SensorSource
65
+ get() = SensorSource.from(prefs.getString(KEY_SOURCE, null))
66
+ set(value) = prefs.edit().putString(KEY_SOURCE, value.jsValue).apply()
67
+
68
+ var lastEventAt: Long
69
+ get() = prefs.getLong(KEY_LAST_EVENT_AT, 0L)
70
+ set(value) = prefs.edit().putLong(KEY_LAST_EVENT_AT, value).apply()
71
+
72
+ /** True when the user (or the OS) asked us to come back after reboot. */
73
+ var shouldAutoStart: Boolean
74
+ get() = prefs.getBoolean(KEY_AUTO_START, false)
75
+ set(value) = prefs.edit().putBoolean(KEY_AUTO_START, value).apply()
76
+
77
+ var lastHealthSyncAt: Long
78
+ get() = prefs.getLong(KEY_LAST_HC_SYNC, 0L)
79
+ set(value) = prefs.edit().putLong(KEY_LAST_HC_SYNC, value).apply()
80
+
81
+ /**
82
+ * Version stamp for Health Connect's `clientRecordVersion`, which resolves
83
+ * same-id collisions by keeping the higher value. It must never go
84
+ * backwards, so it cannot simply be the wall clock: after the user moves
85
+ * the clock back, every write would be silently discarded by the provider.
86
+ * Seeded from the clock so records written by earlier versions of this
87
+ * package are still superseded.
88
+ */
89
+ @Synchronized
90
+ fun nextHealthRecordVersion(): Long {
91
+ val next = maxOf(prefs.getLong(KEY_HC_VERSION, 0L) + 1L, System.currentTimeMillis())
92
+ prefs.edit().putLong(KEY_HC_VERSION, next).apply()
93
+ return next
94
+ }
95
+
96
+ /** One atomic write for the hot path, instead of six separate commits. */
97
+ fun writeCounterState(
98
+ bootId: Long,
99
+ anchorValue: Float,
100
+ anchorSteps: Int,
101
+ lastRaw: Float,
102
+ activeDate: String,
103
+ stepsToday: Int,
104
+ lastEventAt: Long,
105
+ lastElapsed: Long
106
+ ) {
107
+ prefs.edit()
108
+ .putLong(KEY_BOOT_ID, bootId)
109
+ .putFloat(KEY_ANCHOR_VALUE, anchorValue)
110
+ .putInt(KEY_ANCHOR_STEPS, anchorSteps)
111
+ .putFloat(KEY_LAST_RAW, lastRaw)
112
+ .putString(KEY_ACTIVE_DATE, activeDate)
113
+ .putInt(KEY_STEPS_TODAY, stepsToday)
114
+ .putLong(KEY_LAST_EVENT_AT, lastEventAt)
115
+ .putLong(KEY_LAST_ELAPSED, lastElapsed)
116
+ .apply()
117
+ }
118
+
119
+ companion object {
120
+ const val PREFS_NAME = "StepTrackerProState"
121
+ const val BOOT_DRIFT_TOLERANCE_MS = 60_000L
122
+
123
+ private const val KEY_BOOT_ID = "boot_id"
124
+ private const val KEY_ANCHOR_VALUE = "anchor_value"
125
+ private const val KEY_ANCHOR_STEPS = "anchor_steps"
126
+ private const val KEY_LAST_RAW = "last_raw_value"
127
+ private const val KEY_LAST_ELAPSED = "last_elapsed_realtime"
128
+ private const val KEY_ACTIVE_DATE = "active_date"
129
+ private const val KEY_STEPS_TODAY = "steps_today"
130
+ private const val KEY_STATE = "tracking_state"
131
+ private const val KEY_SOURCE = "sensor_source"
132
+ private const val KEY_LAST_EVENT_AT = "last_event_at"
133
+ private const val KEY_AUTO_START = "should_auto_start"
134
+ private const val KEY_LAST_HC_SYNC = "last_hc_sync_at"
135
+ private const val KEY_HC_VERSION = "hc_record_version"
136
+ }
137
+ }
@@ -0,0 +1,184 @@
1
+ package com.steptrackerpro.core
2
+
3
+ import android.content.Context
4
+ import org.json.JSONObject
5
+
6
+ /**
7
+ * Immutable snapshot of user config. Persisted as JSON so the foreground
8
+ * service can rebuild it after a process restart without touching JS.
9
+ */
10
+ data class StepTrackerConfig(
11
+ val heightCm: Double = 170.0,
12
+ val weightKg: Double = 70.0,
13
+ /**
14
+ * Explicit override in metres. Zero means "derive from height and sex" -
15
+ * a non-zero default here made [MetricsCalculator.effectiveStride]'s
16
+ * documented fallback unreachable, so height never affected distance.
17
+ */
18
+ val strideLengthM: Double = 0.0,
19
+ val sex: String = "unspecified",
20
+ val dailyGoal: Int = 10_000,
21
+ val weeklyGoal: Int = 70_000,
22
+ val monthlyGoal: Int = 300_000,
23
+ val calorieCoefficient: Double = 0.57,
24
+ val historyRetentionDays: Int = 35,
25
+ val notificationTitle: String? = null,
26
+ val notificationText: String? = null,
27
+ val notificationIcon: String? = null,
28
+ val notificationChannelName: String? = null,
29
+ val notificationActions: Boolean = true,
30
+ val notificationThrottleMs: Long = 1_000L,
31
+ val eventThrottleMs: Long = 500L,
32
+ val persistEveryNSteps: Int = 10,
33
+ val healthConnectEnabled: Boolean = true,
34
+ val healthConnectSyncIntervalMinutes: Int = 30,
35
+ val remoteSyncUrl: String? = null,
36
+ val remoteSyncHeaders: Map<String, String> = emptyMap(),
37
+ val autoStartOnBoot: Boolean = true
38
+ ) {
39
+
40
+ /**
41
+ * Clamps every numeric field into a range the rest of the package can rely
42
+ * on. The TypeScript layer validates a few of these, but it is not the only
43
+ * way in: config is also rebuilt from persisted JSON after a process
44
+ * restart, and the native module can be called directly. A negative
45
+ * `calorieCoefficient` would report negative calories, a zero
46
+ * `persistEveryNSteps` a database write per step, and a negative throttle
47
+ * an event to JS per step.
48
+ */
49
+ fun sanitised(): StepTrackerConfig = copy(
50
+ heightCm = heightCm.coerceIn(50.0, 260.0),
51
+ weightKg = weightKg.coerceIn(10.0, 400.0),
52
+ // Zero is meaningful: it means "derive from height and sex".
53
+ strideLengthM = if (strideLengthM.isFinite()) strideLengthM.coerceIn(0.0, 3.0) else 0.0,
54
+ dailyGoal = dailyGoal.coerceAtLeast(0),
55
+ weeklyGoal = weeklyGoal.coerceAtLeast(0),
56
+ monthlyGoal = monthlyGoal.coerceAtLeast(0),
57
+ calorieCoefficient = if (calorieCoefficient.isFinite()) {
58
+ calorieCoefficient.coerceIn(0.0, 10.0)
59
+ } else {
60
+ StepTrackerConfig().calorieCoefficient
61
+ },
62
+ historyRetentionDays = historyRetentionDays.coerceAtLeast(1),
63
+ notificationThrottleMs = notificationThrottleMs.coerceAtLeast(0L),
64
+ eventThrottleMs = eventThrottleMs.coerceAtLeast(0L),
65
+ persistEveryNSteps = persistEveryNSteps.coerceAtLeast(1),
66
+ healthConnectSyncIntervalMinutes = healthConnectSyncIntervalMinutes.coerceAtLeast(0)
67
+ )
68
+
69
+ fun toJson(): JSONObject = JSONObject().apply {
70
+ put("height", heightCm)
71
+ put("weight", weightKg)
72
+ put("strideLength", strideLengthM)
73
+ put("sex", sex)
74
+ put("dailyGoal", dailyGoal)
75
+ put("weeklyGoal", weeklyGoal)
76
+ put("monthlyGoal", monthlyGoal)
77
+ put("calorieCoefficient", calorieCoefficient)
78
+ put("historyRetentionDays", historyRetentionDays)
79
+ put("notificationTitle", notificationTitle ?: JSONObject.NULL)
80
+ put("notificationText", notificationText ?: JSONObject.NULL)
81
+ put("notificationIcon", notificationIcon ?: JSONObject.NULL)
82
+ put("notificationChannelName", notificationChannelName ?: JSONObject.NULL)
83
+ put("notificationActions", notificationActions)
84
+ put("notificationThrottleMs", notificationThrottleMs)
85
+ put("eventThrottleMs", eventThrottleMs)
86
+ put("persistEveryNSteps", persistEveryNSteps)
87
+ put("healthConnectEnabled", healthConnectEnabled)
88
+ put("healthConnectSyncIntervalMinutes", healthConnectSyncIntervalMinutes)
89
+ put("remoteSyncUrl", remoteSyncUrl ?: JSONObject.NULL)
90
+ put("remoteSyncHeaders", JSONObject(remoteSyncHeaders as Map<*, *>))
91
+ put("autoStartOnBoot", autoStartOnBoot)
92
+ }
93
+
94
+ companion object {
95
+ fun fromJson(json: JSONObject): StepTrackerConfig {
96
+ val fallback = StepTrackerConfig()
97
+ val headers = HashMap<String, String>()
98
+ json.optJSONObject("remoteSyncHeaders")?.let { obj ->
99
+ obj.keys().forEach { key -> headers[key] = obj.optString(key) }
100
+ }
101
+ return StepTrackerConfig(
102
+ heightCm = json.optDouble("height", fallback.heightCm),
103
+ weightKg = json.optDouble("weight", fallback.weightKg),
104
+ strideLengthM = json.optDouble("strideLength", fallback.strideLengthM),
105
+ sex = json.optString("sex", fallback.sex),
106
+ dailyGoal = json.optInt("dailyGoal", fallback.dailyGoal),
107
+ weeklyGoal = json.optInt("weeklyGoal", fallback.weeklyGoal),
108
+ monthlyGoal = json.optInt("monthlyGoal", fallback.monthlyGoal),
109
+ calorieCoefficient = json.optDouble(
110
+ "calorieCoefficient", fallback.calorieCoefficient
111
+ ),
112
+ historyRetentionDays = json.optInt(
113
+ "historyRetentionDays", fallback.historyRetentionDays
114
+ ),
115
+ notificationTitle = json.optStringOrNull("notificationTitle"),
116
+ notificationText = json.optStringOrNull("notificationText"),
117
+ notificationIcon = json.optStringOrNull("notificationIcon"),
118
+ notificationChannelName = json.optStringOrNull("notificationChannelName"),
119
+ notificationActions = json.optBoolean(
120
+ "notificationActions", fallback.notificationActions
121
+ ),
122
+ notificationThrottleMs = json.optLong(
123
+ "notificationThrottleMs", fallback.notificationThrottleMs
124
+ ),
125
+ eventThrottleMs = json.optLong("eventThrottleMs", fallback.eventThrottleMs),
126
+ persistEveryNSteps = json.optInt(
127
+ "persistEveryNSteps", fallback.persistEveryNSteps
128
+ ),
129
+ healthConnectEnabled = json.optBoolean(
130
+ "healthConnectEnabled", fallback.healthConnectEnabled
131
+ ),
132
+ healthConnectSyncIntervalMinutes = json.optInt(
133
+ "healthConnectSyncIntervalMinutes",
134
+ fallback.healthConnectSyncIntervalMinutes
135
+ ),
136
+ remoteSyncUrl = json.optStringOrNull("remoteSyncUrl"),
137
+ remoteSyncHeaders = headers,
138
+ autoStartOnBoot = json.optBoolean("autoStartOnBoot", fallback.autoStartOnBoot)
139
+ )
140
+ }
141
+
142
+ private fun JSONObject.optStringOrNull(key: String): String? =
143
+ if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
144
+ }
145
+ }
146
+
147
+ /** Single source of truth for config, shared by the module and the service. */
148
+ class ConfigStore(context: Context) {
149
+
150
+ private val prefs = context.applicationContext
151
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
152
+
153
+ @Volatile
154
+ private var cached: StepTrackerConfig? = null
155
+
156
+ @Synchronized
157
+ fun get(): StepTrackerConfig {
158
+ cached?.let { return it }
159
+ val raw = prefs.getString(KEY_CONFIG, null)
160
+ val parsed = if (raw.isNullOrEmpty()) {
161
+ StepTrackerConfig()
162
+ } else {
163
+ runCatching { StepTrackerConfig.fromJson(JSONObject(raw)) }
164
+ .getOrElse { StepTrackerConfig() }
165
+ }
166
+ cached = parsed.sanitised()
167
+ return cached!!
168
+ }
169
+
170
+ @Synchronized
171
+ fun save(config: StepTrackerConfig): StepTrackerConfig {
172
+ val safe = config.sanitised()
173
+ cached = safe
174
+ prefs.edit().putString(KEY_CONFIG, safe.toJson().toString()).apply()
175
+ return safe
176
+ }
177
+
178
+ fun isInitialized(): Boolean = prefs.contains(KEY_CONFIG)
179
+
180
+ companion object {
181
+ const val PREFS_NAME = "StepTrackerProConfig"
182
+ private const val KEY_CONFIG = "config_json"
183
+ }
184
+ }
@@ -0,0 +1,280 @@
1
+ package com.steptrackerpro.core
2
+
3
+ import android.content.Context
4
+ import com.steptrackerpro.db.StepRepository
5
+ import com.steptrackerpro.health.HealthConnectManager
6
+ import com.steptrackerpro.sync.SyncScheduler
7
+ import com.steptrackerpro.util.StepEventBus
8
+ import kotlinx.coroutines.CoroutineScope
9
+ import kotlinx.coroutines.Dispatchers
10
+ import kotlinx.coroutines.SupervisorJob
11
+ import kotlinx.coroutines.launch
12
+ import kotlinx.coroutines.sync.Mutex
13
+ import kotlinx.coroutines.sync.withLock
14
+ import java.util.concurrent.atomic.AtomicLong
15
+
16
+ /**
17
+ * Process-wide singleton shared by the React module, the foreground service,
18
+ * the boot receiver and the workers. Holding one instance is what keeps the
19
+ * counter consistent when JS is dead but the service is alive.
20
+ */
21
+ class StepTrackerCore private constructor(context: Context) {
22
+
23
+ private val appContext: Context = context.applicationContext
24
+
25
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
26
+ val configStore = ConfigStore(appContext)
27
+ val state = StepStateStore(appContext)
28
+ val metrics = MetricsCalculator(configStore.get())
29
+ val repository = StepRepository(appContext)
30
+ val goals = GoalTracker(appContext)
31
+ val healthConnect = HealthConnectManager(appContext, state)
32
+
33
+ val engine = StepCounterEngine(state, metrics).apply {
34
+ onDayRollover = { closing, newDate -> handleRollover(closing, newDate) }
35
+ }
36
+
37
+ private val lastEventAt = AtomicLong(0L)
38
+ private val syncMutex = Mutex()
39
+
40
+ fun config(): StepTrackerConfig = configStore.get()
41
+
42
+ fun updateConfig(config: StepTrackerConfig): StepTrackerConfig {
43
+ val saved = configStore.save(config)
44
+ metrics.config = saved
45
+ SyncScheduler.schedule(appContext, saved)
46
+ return saved
47
+ }
48
+
49
+ fun isInitialized(): Boolean = configStore.isInitialized()
50
+
51
+ // ---- step pipeline ---------------------------------------------------
52
+
53
+ /**
54
+ * Called from the sensor callback for every changed total. Emits a
55
+ * throttled `stepsChanged`, fires goal events, and flushes to the database
56
+ * every `persistEveryNSteps`.
57
+ */
58
+ fun onStepsChanged(snapshot: StepSnapshot, force: Boolean = false) {
59
+ val config = config()
60
+
61
+ val now = System.currentTimeMillis()
62
+ val previous = lastEventAt.get()
63
+ if (force || now - previous >= config.eventThrottleMs) {
64
+ lastEventAt.set(now)
65
+ StepEventBus.emit(
66
+ StepEventBus.Events.STEPS_CHANGED,
67
+ com.steptrackerpro.util.Bridge.snapshotMap(snapshot)
68
+ )
69
+ }
70
+
71
+ val percent = metrics.goalPercent(snapshot.steps, config.dailyGoal)
72
+ if (goals.progressBucketChanged(GoalTracker.TYPE_DAILY, percent)) {
73
+ StepEventBus.emit(
74
+ StepEventBus.Events.GOAL_PROGRESS_CHANGED,
75
+ mapOf(
76
+ "type" to GoalTracker.TYPE_DAILY,
77
+ "goal" to config.dailyGoal,
78
+ "steps" to snapshot.steps,
79
+ "progress" to snapshot.goalProgress,
80
+ "date" to snapshot.date
81
+ )
82
+ )
83
+ }
84
+
85
+ goals.checkDaily(snapshot.date, snapshot.steps, config.dailyGoal)?.let { reached ->
86
+ emitGoalReached(reached, snapshot.date)
87
+ }
88
+
89
+ if (force || engine.shouldCommit(config.persistEveryNSteps)) {
90
+ val totals = engine.consumeCommit()
91
+ scope.launch {
92
+ repository.saveDay(totals)
93
+ checkPeriodGoals(totals)
94
+ }
95
+ }
96
+ }
97
+
98
+ private fun emitGoalReached(reached: GoalTracker.Reached, date: String) {
99
+ StepEventBus.emit(
100
+ StepEventBus.Events.GOAL_REACHED,
101
+ mapOf(
102
+ "type" to reached.type,
103
+ "goal" to reached.goal,
104
+ "steps" to reached.steps,
105
+ "date" to date,
106
+ "timestamp" to System.currentTimeMillis()
107
+ )
108
+ )
109
+ }
110
+
111
+ /**
112
+ * Runs on every commit, so it must not query unconditionally: once a period
113
+ * goal has fired there is nothing left to learn from summing that period
114
+ * again, and at `persistEveryNSteps = 10` this path is reached every few
115
+ * seconds for the length of a walk.
116
+ */
117
+ private suspend fun checkPeriodGoals(totals: DayTotals) {
118
+ val config = config()
119
+
120
+ val (weekStart, weekEnd) = DateKeys.calendarWeek()
121
+ if (config.weeklyGoal > 0 && !goals.alreadyFired(GoalTracker.TYPE_WEEKLY, weekStart)) {
122
+ goals.checkWeekly(repository.sumSteps(weekStart, weekEnd), config.weeklyGoal)?.let {
123
+ emitGoalReached(it, totals.date)
124
+ }
125
+ }
126
+
127
+ val (monthStart, monthEnd) = DateKeys.calendarMonth()
128
+ if (config.monthlyGoal > 0 && !goals.alreadyFired(GoalTracker.TYPE_MONTHLY, monthStart)) {
129
+ goals.checkMonthly(repository.sumSteps(monthStart, monthEnd), config.monthlyGoal)?.let {
130
+ emitGoalReached(it, totals.date)
131
+ }
132
+ }
133
+ }
134
+
135
+ /** Persists the closing day, tells JS, and trims history past retention. */
136
+ private fun handleRollover(closing: DayTotals, newDate: String) {
137
+ scope.launch {
138
+ repository.saveDay(closing)
139
+ // The local date can also move backwards - travelling west across
140
+ // the date line - in which case the day being adopted is one that
141
+ // already has steps against it. Restore them so the user does not
142
+ // land to a counter reading zero halfway through their day.
143
+ if (newDate < closing.date) {
144
+ engine.seedActiveDay(newDate, repository.getDay(newDate).steps)
145
+ }
146
+ repository.prune(config().historyRetentionDays)
147
+ StepEventBus.emit(
148
+ StepEventBus.Events.DAY_CHANGED,
149
+ mapOf(
150
+ "previousDate" to closing.date,
151
+ "currentDate" to newDate,
152
+ "previousDaySteps" to closing.steps
153
+ )
154
+ )
155
+ if (config().healthConnectEnabled) syncHealthConnect()
156
+ }
157
+ }
158
+
159
+ /** Writes whatever is in memory to the database. Called before shutdown. */
160
+ fun flush(): DayTotals {
161
+ val totals = engine.consumeCommit()
162
+ scope.launch { repository.saveDay(totals) }
163
+ return totals
164
+ }
165
+
166
+ fun emitTrackingState(reason: String? = null) {
167
+ val snapshot = engine.snapshot()
168
+ StepEventBus.emit(
169
+ StepEventBus.Events.TRACKING_STATE_CHANGED,
170
+ mapOf(
171
+ "state" to snapshot.state.jsValue,
172
+ "source" to snapshot.source.jsValue,
173
+ "reason" to reason
174
+ )
175
+ )
176
+ }
177
+
178
+ // ---- reads -----------------------------------------------------------
179
+
180
+ /** Today's live totals, straight from the counter rather than the database. */
181
+ fun liveToday(): DayTotals {
182
+ val snapshot = engine.snapshot()
183
+ return DayTotals(
184
+ snapshot.date,
185
+ snapshot.steps,
186
+ snapshot.distance,
187
+ snapshot.calories,
188
+ false
189
+ )
190
+ }
191
+
192
+ suspend fun stats(start: String, end: String, goal: Int?): RangeStats {
193
+ val today = DateKeys.today()
194
+ val live = if (today in start..end) liveToday() else null
195
+ return repository.stats(start, end, goal, live)
196
+ }
197
+
198
+ suspend fun dayTotals(date: String): DayTotals =
199
+ if (date == DateKeys.today()) liveToday() else repository.getDay(date)
200
+
201
+ // ---- sync ------------------------------------------------------------
202
+
203
+ /** Pushes every unsynced day (plus today) into Health Connect. */
204
+ suspend fun syncHealthConnect(): Map<String, Any?> = syncMutex.withLock {
205
+ val config = config()
206
+ // These three are states the user has to change, not transient faults.
207
+ // Reporting them as retryable would have the periodic worker back off
208
+ // and try again forever on a device that will never succeed.
209
+ if (!config.healthConnectEnabled) {
210
+ return@withLock syncResult("health_connect", 0, 0, false, "Disabled in config")
211
+ }
212
+ if (healthConnect.availability() != HealthConnectManager.Availability.AVAILABLE) {
213
+ return@withLock syncResult(
214
+ "health_connect", 0, 0, false, "Health Connect unavailable"
215
+ )
216
+ }
217
+ if (!healthConnect.hasAllPermissions()) {
218
+ return@withLock syncResult(
219
+ "health_connect", 0, 0, false, "Health Connect permissions not granted"
220
+ )
221
+ }
222
+
223
+ // Today is written every time so the mirror stays close to live; older
224
+ // days are only written while they are still marked unsynced.
225
+ val today = liveToday()
226
+ repository.saveDay(today)
227
+ val pending = repository.unsynced(SyncTarget.HEALTH_CONNECT, limit = 100)
228
+ // Today first, so its live totals win over the row just written for it.
229
+ val targets = (listOf(today) + pending).distinctBy { it.date }
230
+
231
+ var succeeded = 0
232
+ var failed = 0
233
+ val syncedDates = ArrayList<String>(targets.size)
234
+ for (day in targets) {
235
+ if (healthConnect.writeDay(day)) {
236
+ succeeded++
237
+ // Today stays unsynced: it is still moving.
238
+ if (day.date != today.date) syncedDates.add(day.date)
239
+ } else {
240
+ failed++
241
+ }
242
+ }
243
+ repository.markSynced(SyncTarget.HEALTH_CONNECT, syncedDates)
244
+ state.lastHealthSyncAt = System.currentTimeMillis()
245
+
246
+ // A write that failed against an available, permitted provider is worth
247
+ // another attempt; nothing else here is.
248
+ val result = syncResult(
249
+ "health_connect", succeeded, failed, failed == 0, null, retryable = failed > 0
250
+ )
251
+ StepEventBus.emit(StepEventBus.Events.SYNC_COMPLETED, result)
252
+ result
253
+ }
254
+
255
+ private fun syncResult(
256
+ target: String,
257
+ synced: Int,
258
+ failed: Int,
259
+ success: Boolean,
260
+ error: String?,
261
+ retryable: Boolean = false
262
+ ): Map<String, Any?> = mapOf(
263
+ "target" to target,
264
+ "syncedRecords" to synced,
265
+ "failedRecords" to failed,
266
+ "success" to success,
267
+ "error" to error,
268
+ "retryable" to retryable
269
+ )
270
+
271
+ companion object {
272
+ @Volatile
273
+ private var instance: StepTrackerCore? = null
274
+
275
+ fun get(context: Context): StepTrackerCore =
276
+ instance ?: synchronized(this) {
277
+ instance ?: StepTrackerCore(context).also { instance = it }
278
+ }
279
+ }
280
+ }
@@ -0,0 +1,67 @@
1
+ package com.steptrackerpro.db
2
+
3
+ import androidx.room.ColumnInfo
4
+ import androidx.room.Entity
5
+ import androidx.room.Index
6
+ import androidx.room.PrimaryKey
7
+
8
+ /**
9
+ * Append-only-ish log of day totals. One row per day per write cycle target:
10
+ * the row for a given date is updated in place while that day is active, and
11
+ * frozen once the day rolls over.
12
+ */
13
+ @Entity(
14
+ tableName = "step_history",
15
+ indices = [
16
+ Index(value = ["date"], unique = true),
17
+ Index(value = ["synced"]),
18
+ Index(value = ["syncedRemote"])
19
+ ]
20
+ )
21
+ data class StepHistoryEntity(
22
+ @PrimaryKey(autoGenerate = true)
23
+ val id: Long = 0L,
24
+
25
+ /** yyyy-MM-dd, device local. */
26
+ val date: String,
27
+
28
+ val steps: Int,
29
+
30
+ /** Metres. */
31
+ val distance: Double,
32
+
33
+ /** Kilocalories. */
34
+ val calories: Double,
35
+
36
+ /**
37
+ * 0 = pending, 1 = mirrored to Health Connect. Kept under its original
38
+ * column name so existing rows migrate without a rewrite.
39
+ */
40
+ @ColumnInfo(name = "synced")
41
+ val syncedHealth: Boolean = false,
42
+
43
+ /** 0 = pending, 1 = uploaded to the configured remote endpoint. */
44
+ @ColumnInfo(name = "syncedRemote", defaultValue = "0")
45
+ val syncedRemote: Boolean = false,
46
+
47
+ @ColumnInfo(name = "createdAt")
48
+ val createdAt: Long = System.currentTimeMillis(),
49
+
50
+ @ColumnInfo(name = "updatedAt")
51
+ val updatedAt: Long = System.currentTimeMillis()
52
+ )
53
+
54
+ /**
55
+ * Rolled-up totals, kept separate so statistics queries never contend with the
56
+ * hot write path on `step_history`.
57
+ */
58
+ @Entity(tableName = "daily_summary")
59
+ data class DailySummaryEntity(
60
+ @PrimaryKey
61
+ val date: String,
62
+ val totalSteps: Int,
63
+ val totalDistance: Double,
64
+ val totalCalories: Double,
65
+ @ColumnInfo(name = "updatedAt")
66
+ val updatedAt: Long = System.currentTimeMillis()
67
+ )