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,170 @@
1
+ package com.steptrackerpro.service
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.PendingIntent
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.os.Build
10
+ import androidx.core.app.NotificationCompat
11
+ import com.steptrackerpro.R
12
+ import com.steptrackerpro.core.MetricsCalculator
13
+ import com.steptrackerpro.core.StepSnapshot
14
+ import com.steptrackerpro.core.StepTrackerConfig
15
+ import com.steptrackerpro.core.TrackingState
16
+ import java.text.NumberFormat
17
+ import java.util.Locale
18
+
19
+ /**
20
+ * Builds the ongoing notification. The channel is created at IMPORTANCE_LOW so
21
+ * the per-step updates never buzz, and the notification is `ongoing` +
22
+ * `onlyAlertOnce` so Android reuses the same row instead of animating.
23
+ */
24
+ class NotificationFactory(private val context: Context) {
25
+
26
+ private val manager =
27
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
28
+
29
+ fun ensureChannel(config: StepTrackerConfig) {
30
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
31
+ val name = config.notificationChannelName
32
+ ?: context.getString(R.string.stp_channel_name)
33
+ val channel = NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW)
34
+ .apply {
35
+ description = context.getString(R.string.stp_channel_description)
36
+ setShowBadge(false)
37
+ enableVibration(false)
38
+ enableLights(false)
39
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
40
+ }
41
+ manager.createNotificationChannel(channel)
42
+ }
43
+
44
+ fun build(
45
+ snapshot: StepSnapshot,
46
+ config: StepTrackerConfig,
47
+ metrics: MetricsCalculator
48
+ ): Notification {
49
+ val paused = snapshot.state == TrackingState.PAUSED
50
+ val percent = metrics.goalPercent(snapshot.steps, config.dailyGoal)
51
+ val steps = formatCount(snapshot.steps)
52
+ val km = String.format(Locale.US, "%.2f", snapshot.distance / 1000.0)
53
+ val kcal = formatCount(snapshot.calories.toInt())
54
+
55
+ val title = config.notificationTitle?.applyTokens(steps, km, kcal, percent, config)
56
+ ?: "$steps steps"
57
+ val text = when {
58
+ paused -> context.getString(R.string.stp_paused_text)
59
+ config.notificationText != null ->
60
+ config.notificationText.applyTokens(steps, km, kcal, percent, config)
61
+ else -> context.getString(R.string.stp_notification_text, km, kcal, percent)
62
+ }
63
+
64
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
65
+ .setSmallIcon(resolveIcon(config))
66
+ .setContentTitle(title)
67
+ .setContentText(text)
68
+ .setSubText("${formatCount(config.dailyGoal)} goal")
69
+ .setProgress(100, percent, false)
70
+ .setOngoing(true)
71
+ .setOnlyAlertOnce(true)
72
+ .setShowWhen(false)
73
+ .setSilent(true)
74
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
75
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
76
+ .setPriority(NotificationCompat.PRIORITY_LOW)
77
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
78
+ .setContentIntent(openAppIntent())
79
+
80
+ if (config.notificationActions) {
81
+ if (paused) {
82
+ builder.addAction(
83
+ 0,
84
+ context.getString(R.string.stp_action_resume),
85
+ actionIntent(ServiceCommands.ACTION_RESUME, REQUEST_RESUME)
86
+ )
87
+ } else {
88
+ builder.addAction(
89
+ 0,
90
+ context.getString(R.string.stp_action_pause),
91
+ actionIntent(ServiceCommands.ACTION_PAUSE, REQUEST_PAUSE)
92
+ )
93
+ }
94
+ openAppIntent()?.let { open ->
95
+ builder.addAction(0, context.getString(R.string.stp_action_open), open)
96
+ }
97
+ }
98
+
99
+ return builder.build()
100
+ }
101
+
102
+ fun update(notification: Notification) {
103
+ runCatching { manager.notify(NOTIFICATION_ID, notification) }
104
+ }
105
+
106
+ private fun resolveIcon(config: StepTrackerConfig): Int {
107
+ val custom = config.notificationIcon ?: return R.drawable.stp_ic_steps
108
+ val id = context.resources.getIdentifier(custom, "drawable", context.packageName)
109
+ return if (id != 0) id else R.drawable.stp_ic_steps
110
+ }
111
+
112
+ /**
113
+ * Launches the host app's main activity, reusing the existing task. Null when
114
+ * the app has no launcher activity: wrapping an empty Intent would produce a
115
+ * PendingIntent that silently does nothing when tapped, which is worse than
116
+ * a notification that is plainly not tappable.
117
+ */
118
+ private fun openAppIntent(): PendingIntent? {
119
+ val launch = context.packageManager
120
+ .getLaunchIntentForPackage(context.packageName)
121
+ ?.apply {
122
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
123
+ putExtra(EXTRA_FROM_NOTIFICATION, true)
124
+ }
125
+ ?: return null
126
+ return PendingIntent.getActivity(context, REQUEST_OPEN, launch, flags())
127
+ }
128
+
129
+ private fun actionIntent(action: String, requestCode: Int): PendingIntent {
130
+ val intent = Intent(context, NotificationActionReceiver::class.java).setAction(action)
131
+ return PendingIntent.getBroadcast(context, requestCode, intent, flags())
132
+ }
133
+
134
+ private fun flags(): Int =
135
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
136
+
137
+ private fun String.applyTokens(
138
+ steps: String,
139
+ km: String,
140
+ kcal: String,
141
+ percent: Int,
142
+ config: StepTrackerConfig
143
+ ): String = this
144
+ .replace("{steps}", steps)
145
+ .replace("{distance}", km)
146
+ .replace("{calories}", kcal)
147
+ .replace("{percent}", percent.toString())
148
+ .replace("{goal}", formatCount(config.dailyGoal))
149
+
150
+ companion object {
151
+ const val CHANNEL_ID = "step_tracker_pro"
152
+ const val NOTIFICATION_ID = 8_143
153
+ const val EXTRA_FROM_NOTIFICATION = "stp_from_notification"
154
+
155
+ private const val REQUEST_OPEN = 3101
156
+ private const val REQUEST_PAUSE = 3102
157
+ private const val REQUEST_RESUME = 3103
158
+
159
+ /**
160
+ * NumberFormat is not thread safe, and notifications are now built from
161
+ * both the sensor thread and the main thread. One instance per thread
162
+ * is cheaper than locking on a path that runs once a second.
163
+ */
164
+ private val numberFormat: ThreadLocal<NumberFormat> =
165
+ ThreadLocal.withInitial { NumberFormat.getIntegerInstance(Locale.getDefault()) }
166
+
167
+ private fun formatCount(value: Int): String =
168
+ numberFormat.get()?.format(value) ?: value.toString()
169
+ }
170
+ }
@@ -0,0 +1,42 @@
1
+ package com.steptrackerpro.service
2
+
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.os.Build
6
+
7
+ /** Every entry point into [StepTrackerService] goes through here. */
8
+ object ServiceCommands {
9
+
10
+ const val ACTION_START = "com.steptrackerpro.action.START"
11
+ const val ACTION_PAUSE = "com.steptrackerpro.action.PAUSE"
12
+ const val ACTION_RESUME = "com.steptrackerpro.action.RESUME"
13
+ const val ACTION_STOP = "com.steptrackerpro.action.STOP"
14
+ const val ACTION_CONFIG_CHANGED = "com.steptrackerpro.action.CONFIG_CHANGED"
15
+ const val ACTION_OPEN_APP = "com.steptrackerpro.action.OPEN_APP"
16
+ const val ACTION_REFRESH = "com.steptrackerpro.action.REFRESH"
17
+
18
+ const val EXTRA_FROM_BOOT = "from_boot"
19
+
20
+ fun intent(context: Context, action: String): Intent =
21
+ Intent(context, StepTrackerService::class.java).setAction(action)
22
+
23
+ fun start(context: Context, fromBoot: Boolean = false) {
24
+ val intent = intent(context, ACTION_START).putExtra(EXTRA_FROM_BOOT, fromBoot)
25
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
26
+ context.startForegroundService(intent)
27
+ } else {
28
+ context.startService(intent)
29
+ }
30
+ }
31
+
32
+ fun send(context: Context, action: String) {
33
+ val intent = intent(context, action)
34
+ runCatching {
35
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
36
+ context.startForegroundService(intent)
37
+ } else {
38
+ context.startService(intent)
39
+ }
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,409 @@
1
+ package com.steptrackerpro.service
2
+
3
+ import android.app.Service
4
+ import android.content.Intent
5
+ import android.content.pm.ServiceInfo
6
+ import android.hardware.Sensor
7
+ import android.hardware.SensorEvent
8
+ import android.hardware.SensorEventListener
9
+ import android.hardware.SensorManager
10
+ import android.os.Build
11
+ import android.os.Handler
12
+ import android.os.HandlerThread
13
+ import android.os.IBinder
14
+ import android.os.SystemClock
15
+ import android.util.Log
16
+ import com.steptrackerpro.core.SensorSource
17
+ import com.steptrackerpro.core.StepTrackerCore
18
+ import com.steptrackerpro.core.TrackingState
19
+ import com.steptrackerpro.sync.SyncScheduler
20
+ import com.steptrackerpro.util.PermissionHelper
21
+ import com.steptrackerpro.util.StepEventBus
22
+
23
+ /**
24
+ * The only component that touches the sensors.
25
+ *
26
+ * Runs as a `health` foreground service so Android keeps it alive with the
27
+ * screen off and the app swiped away. START_STICKY plus persisted counter state
28
+ * means a kill by the OS costs at most the steps taken between the last
29
+ * SharedPreferences write and the restart — and with TYPE_STEP_COUNTER even
30
+ * those are recovered, because the hardware kept counting.
31
+ */
32
+ class StepTrackerService : Service(), SensorEventListener {
33
+
34
+ private lateinit var core: StepTrackerCore
35
+ private lateinit var notifications: NotificationFactory
36
+
37
+ private var sensorManager: SensorManager? = null
38
+ private var counterSensor: Sensor? = null
39
+ private var detectorSensor: Sensor? = null
40
+ private var listening = false
41
+
42
+ /**
43
+ * Sensor callbacks would otherwise be delivered on the main looper, which is
44
+ * where `registerListener` puts them when no handler is supplied. Every
45
+ * sample writes SharedPreferences and every notification push is a binder
46
+ * call, once per step; none of that belongs on the UI thread. The engine is
47
+ * fully synchronized, so moving the callbacks is safe.
48
+ */
49
+ private var sensorThread: HandlerThread? = null
50
+ private var sensorHandler: Handler? = null
51
+
52
+ @Volatile
53
+ private var lastNotificationAt = 0L
54
+
55
+ @Volatile
56
+ private var lastNotifiedSteps = -1
57
+
58
+ override fun onCreate() {
59
+ super.onCreate()
60
+ core = StepTrackerCore.get(this)
61
+ notifications = NotificationFactory(this)
62
+ sensorManager = getSystemService(SENSOR_SERVICE) as? SensorManager
63
+ counterSensor = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
64
+ detectorSensor = sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)
65
+ sensorThread = HandlerThread("stp-sensor").also {
66
+ it.start()
67
+ sensorHandler = Handler(it.looper)
68
+ }
69
+ notifications.ensureChannel(core.config())
70
+ }
71
+
72
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
73
+ val action = intent?.action
74
+ // A null intent is the OS restarting us after a kill; anything else with
75
+ // no action, or ACTION_START, is a deliberate start.
76
+ val explicitStart =
77
+ intent != null && (action == null || action == ServiceCommands.ACTION_START)
78
+
79
+ // Promote even though we are about to stop. Stopping inside the
80
+ // startForegroundService() window is *supposed* to satisfy the OS
81
+ // deadline on its own, but a service that never completes
82
+ // startForeground() is killed with
83
+ // ForegroundServiceDidNotStartInTimeException, and that is not a race
84
+ // worth taking on a path the user hits deliberately. When the service
85
+ // was already in the foreground this is a no-op re-post.
86
+ if (action == ServiceCommands.ACTION_STOP) {
87
+ promoteToForeground()
88
+ stopTracking()
89
+ return START_NOT_STICKY
90
+ }
91
+
92
+ // Every command arrives through startForegroundService(), which starts
93
+ // the service in order to deliver it. Only an explicit start may leave a
94
+ // service running for a tracker the user has stopped: that service
95
+ // returns START_STICKY, and the OS restarting it would resurrect
96
+ // tracking nobody asked for.
97
+ if (!explicitStart && !core.state.shouldAutoStart) {
98
+ stopForegroundCompat()
99
+ stopSelf()
100
+ return START_NOT_STICKY
101
+ }
102
+
103
+ // Must happen inside 5s of startForegroundService, before anything that
104
+ // can throw, or the OS raises ForegroundServiceDidNotStartInTimeException.
105
+ if (!promoteToForeground()) return abandon()
106
+
107
+ when (action) {
108
+ ServiceCommands.ACTION_PAUSE -> pause()
109
+ ServiceCommands.ACTION_RESUME -> resume()
110
+ ServiceCommands.ACTION_CONFIG_CHANGED -> {
111
+ notifications.ensureChannel(core.config())
112
+ pushNotification(force = true)
113
+ }
114
+ ServiceCommands.ACTION_REFRESH -> pushNotification(force = true)
115
+ else -> {
116
+ val fromBoot =
117
+ intent?.getBooleanExtra(ServiceCommands.EXTRA_FROM_BOOT, false) == true
118
+ // A restart or a boot must honour what the user last asked for
119
+ // rather than starting fresh, pause included.
120
+ startTracking(fromBoot = fromBoot, restore = !explicitStart || fromBoot)
121
+ }
122
+ }
123
+
124
+ // Any command can be the one that revives us after the process was
125
+ // killed. Claiming to be running without a listener attached is the
126
+ // silent-stop failure this guard exists to prevent.
127
+ if (!listening && core.state.trackingState == TrackingState.RUNNING) {
128
+ registerSensors()
129
+ }
130
+
131
+ // START_STICKY: if the OS kills us for memory, restart with a null
132
+ // intent and fall through to startTracking() above.
133
+ return START_STICKY
134
+ }
135
+
136
+ // ---- lifecycle -------------------------------------------------------
137
+
138
+ /**
139
+ * @param restore true when the start was not a deliberate user action — a
140
+ * sticky restart or a boot — in which case a pause the user set before the
141
+ * process died must survive it.
142
+ */
143
+ private fun startTracking(fromBoot: Boolean, restore: Boolean = false) {
144
+ if (!PermissionHelper.canStartTracking(this)) {
145
+ StepEventBus.emit(
146
+ StepEventBus.Events.ERROR,
147
+ mapOf(
148
+ "code" to "E_PERMISSION_DENIED",
149
+ "message" to "ACTIVITY_RECOGNITION is required to count steps"
150
+ )
151
+ )
152
+ stopTracking()
153
+ return
154
+ }
155
+
156
+ core.state.shouldAutoStart = true
157
+
158
+ // `paused` lives in memory only, so a restart has to read it back off
159
+ // the persisted tracking state. setPaused() ignores a no-op transition,
160
+ // which is what keeps the steps the hardware counted while the process
161
+ // was dead from being discarded by a spurious re-anchor.
162
+ val paused = restore && core.state.trackingState == TrackingState.PAUSED
163
+ core.engine.setPaused(paused)
164
+ core.engine.reconcile()
165
+
166
+ if (!registerSensors()) {
167
+ core.state.trackingState = TrackingState.UNSUPPORTED
168
+ core.state.source = SensorSource.NONE
169
+ StepEventBus.emit(
170
+ StepEventBus.Events.ERROR,
171
+ mapOf(
172
+ "code" to "E_NO_SENSOR",
173
+ "message" to "This device exposes neither TYPE_STEP_COUNTER nor TYPE_STEP_DETECTOR"
174
+ )
175
+ )
176
+ core.emitTrackingState("no_sensor")
177
+ stopSelf()
178
+ return
179
+ }
180
+
181
+ core.state.trackingState =
182
+ if (paused) TrackingState.PAUSED else TrackingState.RUNNING
183
+ SyncScheduler.schedule(this, core.config())
184
+ pushNotification(force = true)
185
+ core.emitTrackingState(
186
+ when {
187
+ fromBoot -> "boot"
188
+ restore -> "restored"
189
+ else -> "started"
190
+ }
191
+ )
192
+ }
193
+
194
+ private fun pause() {
195
+ core.engine.setPaused(true)
196
+ core.state.trackingState = TrackingState.PAUSED
197
+ core.flush()
198
+ pushNotification(force = true)
199
+ core.emitTrackingState("paused")
200
+ }
201
+
202
+ private fun resume() {
203
+ core.engine.setPaused(false)
204
+ core.engine.reconcile()
205
+ if (!listening) registerSensors()
206
+ core.state.trackingState = TrackingState.RUNNING
207
+ pushNotification(force = true)
208
+ core.emitTrackingState("resumed")
209
+ }
210
+
211
+ private fun stopTracking() {
212
+ core.state.shouldAutoStart = false
213
+ core.state.trackingState = TrackingState.STOPPED
214
+ core.flush()
215
+ unregisterSensors()
216
+ core.emitTrackingState("stopped")
217
+ stopForegroundCompat()
218
+ stopSelf()
219
+ }
220
+
221
+ /**
222
+ * Leaves after a failed foreground promotion. Carrying on would report the
223
+ * tracker as running on a service the OS is about to kill, and would not
224
+ * stop it before the startForegroundService() deadline expires.
225
+ * `shouldAutoStart` is deliberately left alone so a later start or reboot
226
+ * can still recover from a transient failure.
227
+ */
228
+ private fun abandon(): Int {
229
+ // A pause the user set has to survive this: `restore` reads it back off
230
+ // trackingState, and overwriting it with STOPPED would silently resume
231
+ // counting the next time the service comes up.
232
+ if (core.state.trackingState != TrackingState.PAUSED) {
233
+ core.state.trackingState = TrackingState.STOPPED
234
+ }
235
+ core.emitTrackingState("foreground_start_failed")
236
+ unregisterSensors()
237
+ stopForegroundCompat()
238
+ stopSelf()
239
+ return START_NOT_STICKY
240
+ }
241
+
242
+ // ---- sensors ---------------------------------------------------------
243
+
244
+ private fun registerSensors(): Boolean {
245
+ if (listening) return true
246
+ val manager = sensorManager ?: return false
247
+ val handler = sensorHandler ?: return false
248
+
249
+ counterSensor?.let { sensor ->
250
+ val ok = manager.registerListener(
251
+ this,
252
+ sensor,
253
+ SensorManager.SENSOR_DELAY_NORMAL,
254
+ MAX_REPORT_LATENCY_US,
255
+ handler
256
+ )
257
+ if (ok) {
258
+ listening = true
259
+ core.state.source = SensorSource.STEP_COUNTER
260
+ return true
261
+ }
262
+ }
263
+
264
+ detectorSensor?.let { sensor ->
265
+ val ok = manager.registerListener(
266
+ this,
267
+ sensor,
268
+ SensorManager.SENSOR_DELAY_NORMAL,
269
+ handler
270
+ )
271
+ if (ok) {
272
+ listening = true
273
+ core.state.source = SensorSource.STEP_DETECTOR
274
+ return true
275
+ }
276
+ }
277
+
278
+ return false
279
+ }
280
+
281
+ private fun unregisterSensors() {
282
+ if (!listening) return
283
+ runCatching { sensorManager?.unregisterListener(this) }
284
+ listening = false
285
+ }
286
+
287
+ override fun onSensorChanged(event: SensorEvent?) {
288
+ val sensorEvent = event ?: return
289
+ val values = sensorEvent.values
290
+ if (values == null || values.isEmpty()) return
291
+
292
+ val snapshot = when (sensorEvent.sensor.type) {
293
+ Sensor.TYPE_STEP_COUNTER -> core.engine.onCounterSample(values[0], eventTime(sensorEvent))
294
+ Sensor.TYPE_STEP_DETECTOR -> core.engine.onDetectorSample(1, eventTime(sensorEvent))
295
+ else -> null
296
+ } ?: return
297
+
298
+ core.onStepsChanged(snapshot)
299
+ pushNotification()
300
+ }
301
+
302
+ /**
303
+ * Sensor timestamps are documented as elapsed-realtime nanos, but several
304
+ * OEM HALs report wall-clock nanos instead, which turns the delta into
305
+ * nonsense. Fall back to "now" whenever it lands outside a plausible
306
+ * batching window rather than storing a garbage timestamp.
307
+ */
308
+ private fun eventTime(event: SensorEvent): Long {
309
+ val now = System.currentTimeMillis()
310
+ val ageMs = (SystemClock.elapsedRealtimeNanos() - event.timestamp) / 1_000_000L
311
+ return if (ageMs in 0..MAX_EVENT_AGE_MS) now - ageMs else now
312
+ }
313
+
314
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
315
+
316
+ // ---- notification ----------------------------------------------------
317
+
318
+ /** @return false when the promotion failed and the service must not continue. */
319
+ private fun promoteToForeground(): Boolean {
320
+ val config = core.config()
321
+ notifications.ensureChannel(config)
322
+ val notification = notifications.build(core.engine.snapshot(), config, core.metrics)
323
+ return runCatching {
324
+ if (Build.VERSION.SDK_INT >= 34) {
325
+ startForeground(
326
+ NotificationFactory.NOTIFICATION_ID,
327
+ notification,
328
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
329
+ )
330
+ } else {
331
+ startForeground(NotificationFactory.NOTIFICATION_ID, notification)
332
+ }
333
+ true
334
+ }.onFailure { error ->
335
+ // Catching this does not cancel the system's own deadline: a service
336
+ // started with startForegroundService() that never completes
337
+ // startForeground() is killed with
338
+ // ForegroundServiceDidNotStartInTimeException. The caller stops the
339
+ // service instead of carrying on and reporting itself as running.
340
+ Log.e(TAG, "startForeground failed", error)
341
+ StepEventBus.emit(
342
+ StepEventBus.Events.ERROR,
343
+ mapOf(
344
+ "code" to "E_SERVICE_START_FAILED",
345
+ "message" to (error.message ?: "startForeground failed")
346
+ )
347
+ )
348
+ }.getOrDefault(false)
349
+ }
350
+
351
+ private fun pushNotification(force: Boolean = false) {
352
+ val config = core.config()
353
+ val now = SystemClock.elapsedRealtime()
354
+ val snapshot = core.engine.snapshot()
355
+ if (!force) {
356
+ if (snapshot.steps == lastNotifiedSteps) return
357
+ if (now - lastNotificationAt < config.notificationThrottleMs) return
358
+ }
359
+ lastNotificationAt = now
360
+ lastNotifiedSteps = snapshot.steps
361
+ notifications.update(notifications.build(snapshot, config, core.metrics))
362
+ }
363
+
364
+ private fun stopForegroundCompat() {
365
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
366
+ stopForeground(STOP_FOREGROUND_REMOVE)
367
+ } else {
368
+ @Suppress("DEPRECATION")
369
+ stopForeground(true)
370
+ }
371
+ }
372
+
373
+ // ---- teardown --------------------------------------------------------
374
+
375
+ /**
376
+ * The user swiping the app away must not stop counting, which is the whole
377
+ * point of the service. `stopWithTask=false` in the manifest keeps us
378
+ * alive; this override just makes sure nothing in flight is lost.
379
+ */
380
+ override fun onTaskRemoved(rootIntent: Intent?) {
381
+ core.flush()
382
+ super.onTaskRemoved(rootIntent)
383
+ }
384
+
385
+ override fun onDestroy() {
386
+ unregisterSensors()
387
+ core.flush()
388
+ sensorThread?.quitSafely()
389
+ sensorThread = null
390
+ sensorHandler = null
391
+ super.onDestroy()
392
+ }
393
+
394
+ override fun onBind(intent: Intent?): IBinder? = null
395
+
396
+ companion object {
397
+ private const val TAG = "StepTrackerService"
398
+
399
+ /**
400
+ * Batch window for the step counter. 0 delivers every sample as it
401
+ * happens, which keeps the notification live; the hardware FIFO makes
402
+ * this cheap because the SoC sensor hub does the counting either way.
403
+ */
404
+ private const val MAX_REPORT_LATENCY_US = 0
405
+
406
+ /** Widest plausible age for a batched sample, used to sanity-check HALs. */
407
+ private const val MAX_EVENT_AGE_MS = 60_000L
408
+ }
409
+ }