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