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