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,102 @@
1
+ package com.steptrackerpro.sync
2
+
3
+ import android.content.Context
4
+ import androidx.work.BackoffPolicy
5
+ import androidx.work.Constraints
6
+ import androidx.work.ExistingPeriodicWorkPolicy
7
+ import androidx.work.ExistingWorkPolicy
8
+ import androidx.work.NetworkType
9
+ import androidx.work.OneTimeWorkRequestBuilder
10
+ import androidx.work.PeriodicWorkRequestBuilder
11
+ import androidx.work.WorkManager
12
+ import com.steptrackerpro.core.StepTrackerConfig
13
+ import java.util.concurrent.TimeUnit
14
+
15
+ /**
16
+ * All WorkManager wiring.
17
+ *
18
+ * The two syncs use UPDATE rather than KEEP so a config change takes effect on
19
+ * the next run; UPDATE preserves the existing work's `lastEnqueueTime` and
20
+ * `periodCount`, so calling this on every app launch does not push the next run
21
+ * out and starve the job the way REPLACE would.
22
+ */
23
+ object SyncScheduler {
24
+
25
+ fun schedule(context: Context, config: StepTrackerConfig) {
26
+ val work = WorkManager.getInstance(context)
27
+
28
+ if (config.healthConnectEnabled && config.healthConnectSyncIntervalMinutes > 0) {
29
+ val interval = config.healthConnectSyncIntervalMinutes.toLong()
30
+ .coerceAtLeast(15L) // WorkManager's minimum period
31
+ work.enqueueUniquePeriodicWork(
32
+ HealthConnectSyncWorker.NAME,
33
+ ExistingPeriodicWorkPolicy.UPDATE,
34
+ PeriodicWorkRequestBuilder<HealthConnectSyncWorker>(interval, TimeUnit.MINUTES)
35
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES)
36
+ .build()
37
+ )
38
+ } else {
39
+ work.cancelUniqueWork(HealthConnectSyncWorker.NAME)
40
+ }
41
+
42
+ if (!config.remoteSyncUrl.isNullOrEmpty()) {
43
+ work.enqueueUniquePeriodicWork(
44
+ RemoteSyncWorker.NAME,
45
+ ExistingPeriodicWorkPolicy.UPDATE,
46
+ PeriodicWorkRequestBuilder<RemoteSyncWorker>(1, TimeUnit.HOURS)
47
+ .setConstraints(
48
+ Constraints.Builder()
49
+ .setRequiredNetworkType(NetworkType.CONNECTED)
50
+ .build()
51
+ )
52
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
53
+ .build()
54
+ )
55
+ } else {
56
+ work.cancelUniqueWork(RemoteSyncWorker.NAME)
57
+ }
58
+
59
+ work.enqueueUniquePeriodicWork(
60
+ RetentionWorker.NAME,
61
+ ExistingPeriodicWorkPolicy.KEEP,
62
+ PeriodicWorkRequestBuilder<RetentionWorker>(1, TimeUnit.DAYS).build()
63
+ )
64
+ }
65
+
66
+ /** Fires both syncs immediately, ignoring the periodic schedule. */
67
+ fun runNow(context: Context, config: StepTrackerConfig) {
68
+ val work = WorkManager.getInstance(context)
69
+ if (config.healthConnectEnabled) {
70
+ work.enqueueUniqueWork(
71
+ "${HealthConnectSyncWorker.NAME}_now",
72
+ ExistingWorkPolicy.REPLACE,
73
+ OneTimeWorkRequestBuilder<HealthConnectSyncWorker>().build()
74
+ )
75
+ }
76
+ if (!config.remoteSyncUrl.isNullOrEmpty()) {
77
+ work.enqueueUniqueWork(
78
+ "${RemoteSyncWorker.NAME}_now",
79
+ ExistingWorkPolicy.REPLACE,
80
+ OneTimeWorkRequestBuilder<RemoteSyncWorker>()
81
+ .setConstraints(
82
+ Constraints.Builder()
83
+ .setRequiredNetworkType(NetworkType.CONNECTED)
84
+ .build()
85
+ )
86
+ .build()
87
+ )
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Drops every periodic job, retention included: once tracking is stopped the
93
+ * database is not growing, so a daily wake-up to prune it is pure cost.
94
+ * [schedule] re-enqueues all three when tracking starts again.
95
+ */
96
+ fun cancelAll(context: Context) {
97
+ val work = WorkManager.getInstance(context)
98
+ work.cancelUniqueWork(HealthConnectSyncWorker.NAME)
99
+ work.cancelUniqueWork(RemoteSyncWorker.NAME)
100
+ work.cancelUniqueWork(RetentionWorker.NAME)
101
+ }
102
+ }
@@ -0,0 +1,175 @@
1
+ package com.steptrackerpro.sync
2
+
3
+ import android.content.Context
4
+ import androidx.work.CoroutineWorker
5
+ import androidx.work.WorkerParameters
6
+ import com.steptrackerpro.core.DayTotals
7
+ import com.steptrackerpro.core.StepTrackerCore
8
+ import com.steptrackerpro.core.SyncTarget
9
+ import com.steptrackerpro.util.StepEventBus
10
+ import kotlinx.coroutines.Dispatchers
11
+ import kotlinx.coroutines.withContext
12
+ import kotlinx.coroutines.withTimeoutOrNull
13
+ import org.json.JSONArray
14
+ import org.json.JSONObject
15
+ import java.io.BufferedReader
16
+ import java.net.HttpURLConnection
17
+ import java.net.URL
18
+
19
+ /**
20
+ * Mirrors unsynced day rows into Health Connect. Runs without a network
21
+ * constraint because Health Connect is entirely on-device — offline sync is
22
+ * still sync.
23
+ */
24
+ class HealthConnectSyncWorker(
25
+ context: Context,
26
+ params: WorkerParameters
27
+ ) : CoroutineWorker(context, params) {
28
+
29
+ override suspend fun doWork(): Result {
30
+ val core = StepTrackerCore.get(applicationContext)
31
+ val config = core.config()
32
+ if (!config.healthConnectEnabled) return Result.success()
33
+
34
+ val result = core.syncHealthConnect()
35
+ return when {
36
+ result["success"] == true -> Result.success()
37
+ // Provider missing, permissions not granted, disabled in config:
38
+ // states only the user can change. Retrying backs off and tries
39
+ // again forever on a device that will never succeed.
40
+ result["retryable"] != true -> Result.success()
41
+ runAttemptCount < MAX_ATTEMPTS -> Result.retry()
42
+ // Result.failure() on a PeriodicWorkRequest ends the whole periodic
43
+ // chain, not just this attempt. Give up on this period instead and
44
+ // let the next one come round.
45
+ else -> Result.success()
46
+ }
47
+ }
48
+
49
+ companion object {
50
+ const val NAME = "stp_health_connect_sync"
51
+ private const val MAX_ATTEMPTS = 5
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Ships unsynced day rows to the configured HTTPS endpoint. This is the only
57
+ * part of the package that needs the internet, and nothing else waits on it.
58
+ */
59
+ class RemoteSyncWorker(
60
+ context: Context,
61
+ params: WorkerParameters
62
+ ) : CoroutineWorker(context, params) {
63
+
64
+ override suspend fun doWork(): Result {
65
+ val core = StepTrackerCore.get(applicationContext)
66
+ val config = core.config()
67
+ val url = config.remoteSyncUrl ?: return Result.success()
68
+
69
+ val pending = core.repository.unsynced(SyncTarget.REMOTE, limit = 200)
70
+ if (pending.isEmpty()) return Result.success()
71
+
72
+ val ok = withTimeoutOrNull(REQUEST_TIMEOUT_MS) {
73
+ post(url, config.remoteSyncHeaders, pending)
74
+ } ?: false
75
+
76
+ if (ok) {
77
+ core.repository.markSynced(SyncTarget.REMOTE, pending.map { it.date })
78
+ StepEventBus.emit(
79
+ StepEventBus.Events.SYNC_COMPLETED,
80
+ mapOf(
81
+ "target" to "remote",
82
+ "syncedRecords" to pending.size,
83
+ "failedRecords" to 0,
84
+ "success" to true
85
+ )
86
+ )
87
+ return Result.success()
88
+ }
89
+
90
+ StepEventBus.emit(
91
+ StepEventBus.Events.SYNC_COMPLETED,
92
+ mapOf(
93
+ "target" to "remote",
94
+ "syncedRecords" to 0,
95
+ "failedRecords" to pending.size,
96
+ "success" to false,
97
+ "error" to "Upload failed"
98
+ )
99
+ )
100
+ // As above: failure() would retire the periodic work permanently, so an
101
+ // endpoint that is down for a day would never be retried again.
102
+ return if (runAttemptCount < MAX_ATTEMPTS) Result.retry() else Result.success()
103
+ }
104
+
105
+ private suspend fun post(
106
+ url: String,
107
+ headers: Map<String, String>,
108
+ records: List<DayTotals>
109
+ ): Boolean = withContext(Dispatchers.IO) {
110
+ var connection: HttpURLConnection? = null
111
+ try {
112
+ val body = JSONObject().apply {
113
+ put("source", "react-native-step-tracker-pro")
114
+ put("sentAt", System.currentTimeMillis())
115
+ put(
116
+ "records",
117
+ JSONArray().apply {
118
+ records.forEach { record ->
119
+ put(
120
+ JSONObject().apply {
121
+ put("date", record.date)
122
+ put("steps", record.steps)
123
+ put("distance", record.distance)
124
+ put("calories", record.calories)
125
+ }
126
+ )
127
+ }
128
+ }
129
+ )
130
+ }.toString()
131
+
132
+ connection = (URL(url).openConnection() as HttpURLConnection).apply {
133
+ requestMethod = "POST"
134
+ connectTimeout = 15_000
135
+ readTimeout = 15_000
136
+ doOutput = true
137
+ setRequestProperty("Content-Type", "application/json; charset=utf-8")
138
+ headers.forEach { (key, value) -> setRequestProperty(key, value) }
139
+ }
140
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
141
+ val code = connection.responseCode
142
+ if (code !in 200..299) {
143
+ connection.errorStream?.bufferedReader()?.use(BufferedReader::readText)
144
+ }
145
+ code in 200..299
146
+ } catch (error: Exception) {
147
+ false
148
+ } finally {
149
+ connection?.disconnect()
150
+ }
151
+ }
152
+
153
+ companion object {
154
+ const val NAME = "stp_remote_sync"
155
+ private const val MAX_ATTEMPTS = 5
156
+ private const val REQUEST_TIMEOUT_MS = 45_000L
157
+ }
158
+ }
159
+
160
+ /** Enforces the retention window so the database cannot grow without bound. */
161
+ class RetentionWorker(
162
+ context: Context,
163
+ params: WorkerParameters
164
+ ) : CoroutineWorker(context, params) {
165
+
166
+ override suspend fun doWork(): Result {
167
+ val core = StepTrackerCore.get(applicationContext)
168
+ core.repository.prune(core.config().historyRetentionDays)
169
+ return Result.success()
170
+ }
171
+
172
+ companion object {
173
+ const val NAME = "stp_retention"
174
+ }
175
+ }
@@ -0,0 +1,94 @@
1
+ package com.steptrackerpro.util
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.ComponentName
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.net.Uri
8
+ import android.os.Build
9
+ import android.os.PowerManager
10
+ import android.provider.Settings
11
+
12
+ /**
13
+ * Doze and OEM task-killer helpers.
14
+ *
15
+ * A hardware step counter keeps ticking in the SoC sensor hub even in Doze, so
16
+ * these are about the *service* surviving, not the sensor. On stock Android the
17
+ * foreground service is enough. On aggressive OEM skins (Xiaomi, Oppo, Vivo,
18
+ * Realme, Huawei, Samsung's "put unused apps to sleep") the user has to grant an
19
+ * exemption, and there is no API to do it for them.
20
+ */
21
+ object BatteryOptimizationHelper {
22
+
23
+ fun isOptimizationEnabled(context: Context): Boolean {
24
+ val power = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
25
+ ?: return false
26
+ return !power.isIgnoringBatteryOptimizations(context.packageName)
27
+ }
28
+
29
+ /**
30
+ * Direct system dialog. Requires REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, which
31
+ * Play restricts to apps whose core function genuinely breaks under Doze —
32
+ * declare it in the Play Console or use [openSettings] instead.
33
+ */
34
+ @SuppressLint("BatteryLife")
35
+ fun requestExemption(context: Context): Boolean {
36
+ if (!isOptimizationEnabled(context)) return true
37
+ return runCatching {
38
+ val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
39
+ .setData(Uri.parse("package:${context.packageName}"))
40
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
41
+ context.startActivity(intent)
42
+ true
43
+ }.getOrDefault(false)
44
+ }
45
+
46
+ /** Policy-safe: opens the exemption list, the user picks the app. */
47
+ fun openSettings(context: Context): Boolean = runCatching {
48
+ val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
49
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
50
+ context.startActivity(intent)
51
+ true
52
+ }.getOrDefault(false)
53
+
54
+ /** Best-effort deep link into OEM autostart screens; falls back to app info. */
55
+ fun openAutoStartSettings(context: Context): Boolean {
56
+ val candidates = when (Build.MANUFACTURER.lowercase()) {
57
+ "xiaomi", "redmi", "poco" -> listOf(
58
+ "com.miui.securitycenter" to
59
+ "com.miui.permcenter.autostart.AutoStartManagementActivity"
60
+ )
61
+ "oppo", "realme", "oneplus" -> listOf(
62
+ "com.coloros.safecenter" to
63
+ "com.coloros.safecenter.permission.startup.StartupAppListActivity",
64
+ "com.coloros.safecenter" to
65
+ "com.coloros.safecenter.startupapp.StartupAppListActivity"
66
+ )
67
+ "vivo", "iqoo" -> listOf(
68
+ "com.vivo.permissionmanager" to
69
+ "com.vivo.permissionmanager.activity.BgStartUpManagerActivity"
70
+ )
71
+ "huawei", "honor" -> listOf(
72
+ "com.huawei.systemmanager" to
73
+ "com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"
74
+ )
75
+ "letv" -> listOf(
76
+ "com.letv.android.letvsafe" to
77
+ "com.letv.android.letvsafe.AutobootManageActivity"
78
+ )
79
+ else -> emptyList()
80
+ }
81
+
82
+ for ((pkg, cls) in candidates) {
83
+ val intent = Intent()
84
+ .setComponent(ComponentName(pkg, cls))
85
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
86
+ if (context.packageManager.resolveActivity(intent, 0) != null) {
87
+ val launched = runCatching { context.startActivity(intent); true }
88
+ .getOrDefault(false)
89
+ if (launched) return true
90
+ }
91
+ }
92
+ return PermissionHelper.openAppSettings(context)
93
+ }
94
+ }
@@ -0,0 +1,149 @@
1
+ package com.steptrackerpro.util
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.ReadableArray
5
+ import com.facebook.react.bridge.ReadableMap
6
+ import com.facebook.react.bridge.ReadableType
7
+ import com.facebook.react.bridge.WritableArray
8
+ import com.facebook.react.bridge.WritableMap
9
+ import com.steptrackerpro.core.DayTotals
10
+ import com.steptrackerpro.core.RangeStats
11
+ import com.steptrackerpro.core.StepSnapshot
12
+
13
+ /** Conversions between package models and the React Native bridge. */
14
+ object Bridge {
15
+
16
+ fun snapshot(snapshot: StepSnapshot): WritableMap = Arguments.createMap().apply {
17
+ putString("date", snapshot.date)
18
+ putInt("steps", snapshot.steps)
19
+ putDouble("distance", round(snapshot.distance, 2))
20
+ putDouble("calories", round(snapshot.calories, 2))
21
+ putInt("dailyGoal", snapshot.dailyGoal)
22
+ putDouble("goalProgress", round(snapshot.goalProgress, 4))
23
+ putBoolean("goalReached", snapshot.goalReached)
24
+ putString("state", snapshot.state.jsValue)
25
+ putString("source", snapshot.source.jsValue)
26
+ putDouble("timestamp", snapshot.timestamp.toDouble())
27
+ }
28
+
29
+ fun snapshotMap(snapshot: StepSnapshot): Map<String, Any?> = mapOf(
30
+ "date" to snapshot.date,
31
+ "steps" to snapshot.steps,
32
+ "distance" to round(snapshot.distance, 2),
33
+ "calories" to round(snapshot.calories, 2),
34
+ "dailyGoal" to snapshot.dailyGoal,
35
+ "goalProgress" to round(snapshot.goalProgress, 4),
36
+ "goalReached" to snapshot.goalReached,
37
+ "state" to snapshot.state.jsValue,
38
+ "source" to snapshot.source.jsValue,
39
+ "timestamp" to snapshot.timestamp
40
+ )
41
+
42
+ fun day(totals: DayTotals): WritableMap = Arguments.createMap().apply {
43
+ putString("date", totals.date)
44
+ putInt("steps", totals.steps)
45
+ putDouble("distance", round(totals.distance, 2))
46
+ putDouble("calories", round(totals.calories, 2))
47
+ putBoolean("synced", totals.synced)
48
+ putBoolean("syncedRemote", totals.syncedRemote)
49
+ }
50
+
51
+ fun days(list: List<DayTotals>): WritableArray = Arguments.createArray().apply {
52
+ list.forEach { pushMap(day(it)) }
53
+ }
54
+
55
+ fun stats(stats: RangeStats): WritableMap = Arguments.createMap().apply {
56
+ putString("startDate", stats.startDate)
57
+ putString("endDate", stats.endDate)
58
+ putInt("totalSteps", stats.totalSteps)
59
+ putDouble("totalDistance", round(stats.totalDistance, 2))
60
+ putDouble("totalCalories", round(stats.totalCalories, 2))
61
+ putInt("averageSteps", stats.averageSteps)
62
+ putInt("activeDays", stats.activeDays)
63
+ if (stats.bestDay != null) putMap("bestDay", day(stats.bestDay)) else putNull("bestDay")
64
+ putArray("days", days(stats.days))
65
+ stats.goal?.let { putInt("goal", it) }
66
+ stats.goalProgress?.let { putDouble("goalProgress", round(it, 4)) }
67
+ }
68
+
69
+ fun map(values: Map<String, Any?>): WritableMap = Arguments.createMap().apply {
70
+ values.forEach { (key, value) ->
71
+ when (value) {
72
+ null -> putNull(key)
73
+ is Boolean -> putBoolean(key, value)
74
+ is Int -> putInt(key, value)
75
+ is Long -> putDouble(key, value.toDouble())
76
+ is Float -> putDouble(key, value.toDouble())
77
+ is Double -> putDouble(key, value)
78
+ is String -> putString(key, value)
79
+ is List<*> -> putArray(key, list(value))
80
+ is Map<*, *> -> putMap(key, map(value.entries.associate {
81
+ it.key.toString() to it.value
82
+ }))
83
+ else -> putString(key, value.toString())
84
+ }
85
+ }
86
+ }
87
+
88
+ private fun list(values: List<*>): WritableArray = Arguments.createArray().apply {
89
+ values.forEach { value ->
90
+ when (value) {
91
+ null -> pushNull()
92
+ is Boolean -> pushBoolean(value)
93
+ is Int -> pushInt(value)
94
+ is Long -> pushDouble(value.toDouble())
95
+ is Float -> pushDouble(value.toDouble())
96
+ is Double -> pushDouble(value)
97
+ is String -> pushString(value)
98
+ is List<*> -> pushArray(list(value))
99
+ is Map<*, *> -> pushMap(map(value.entries.associate {
100
+ it.key.toString() to it.value
101
+ }))
102
+ // Mirrors map(): anything else crosses the bridge as its
103
+ // toString rather than being dropped.
104
+ else -> pushString(value.toString())
105
+ }
106
+ }
107
+ }
108
+
109
+ fun stringMap(source: ReadableMap?): Map<String, String> {
110
+ if (source == null) return emptyMap()
111
+ val out = HashMap<String, String>()
112
+ val iterator = source.keySetIterator()
113
+ while (iterator.hasNextKey()) {
114
+ val key = iterator.nextKey()
115
+ if (source.getType(key) == ReadableType.String) {
116
+ source.getString(key)?.let { out[key] = it }
117
+ }
118
+ }
119
+ return out
120
+ }
121
+
122
+ private fun round(value: Double, decimals: Int): Double {
123
+ if (value.isNaN() || value.isInfinite()) return 0.0
124
+ var factor = 1.0
125
+ repeat(decimals) { factor *= 10 }
126
+ return Math.round(value * factor) / factor
127
+ }
128
+ }
129
+
130
+ // Top-level so they can be imported and used outside this file. Extensions
131
+ // declared inside an object are members and are not importable.
132
+
133
+ fun ReadableMap.optDouble(key: String, fallback: Double): Double =
134
+ if (hasKey(key) && !isNull(key)) getDouble(key) else fallback
135
+
136
+ fun ReadableMap.optInt(key: String, fallback: Int): Int =
137
+ if (hasKey(key) && !isNull(key)) getInt(key) else fallback
138
+
139
+ fun ReadableMap.optLong(key: String, fallback: Long): Long =
140
+ if (hasKey(key) && !isNull(key)) getDouble(key).toLong() else fallback
141
+
142
+ fun ReadableMap.optBoolean(key: String, fallback: Boolean): Boolean =
143
+ if (hasKey(key) && !isNull(key)) getBoolean(key) else fallback
144
+
145
+ fun ReadableMap.optString(key: String, fallback: String?): String? =
146
+ if (hasKey(key) && !isNull(key)) getString(key) else fallback
147
+
148
+ fun ReadableArray.toStringList(): List<String> =
149
+ (0 until size()).mapNotNull { getString(it) }
@@ -0,0 +1,92 @@
1
+ package com.steptrackerpro.util
2
+
3
+ import android.Manifest
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.pm.PackageManager
7
+ import android.hardware.Sensor
8
+ import android.hardware.SensorManager
9
+ import android.net.Uri
10
+ import android.os.Build
11
+ import android.provider.Settings
12
+ import androidx.core.content.ContextCompat
13
+
14
+ object PermissionHelper {
15
+
16
+ /** Runtime permissions this package needs, filtered by API level. */
17
+ fun required(): List<String> = buildList {
18
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
19
+ add(Manifest.permission.ACTIVITY_RECOGNITION)
20
+ }
21
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
22
+ add(Manifest.permission.POST_NOTIFICATIONS)
23
+ }
24
+ }
25
+
26
+ fun isGranted(context: Context, permission: String): Boolean =
27
+ ContextCompat.checkSelfPermission(context, permission) ==
28
+ PackageManager.PERMISSION_GRANTED
29
+
30
+ fun hasActivityRecognition(context: Context): Boolean =
31
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
32
+ isGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
33
+ } else {
34
+ true
35
+ }
36
+
37
+ fun hasPostNotifications(context: Context): Boolean =
38
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
39
+ isGranted(context, Manifest.permission.POST_NOTIFICATIONS)
40
+ } else {
41
+ true
42
+ }
43
+
44
+ /**
45
+ * FOREGROUND_SERVICE_HEALTH is a normal permission, so it is granted at
46
+ * install time whenever it is declared in the merged manifest.
47
+ */
48
+ fun hasForegroundServiceHealth(context: Context): Boolean =
49
+ if (Build.VERSION.SDK_INT >= 34) {
50
+ isGranted(context, "android.permission.FOREGROUND_SERVICE_HEALTH")
51
+ } else {
52
+ true
53
+ }
54
+
55
+ /**
56
+ * POST_NOTIFICATIONS is deliberately excluded: a denied notification
57
+ * permission downgrades the notification to invisible on Android 13+, but
58
+ * the foreground service and the counting still work.
59
+ */
60
+ fun canStartTracking(context: Context): Boolean =
61
+ hasActivityRecognition(context) && hasForegroundServiceHealth(context)
62
+
63
+ fun status(context: Context): Map<String, Any?> = mapOf(
64
+ "activityRecognition" to hasActivityRecognition(context),
65
+ "postNotifications" to hasPostNotifications(context),
66
+ "foregroundServiceHealth" to hasForegroundServiceHealth(context),
67
+ "allGranted" to canStartTracking(context)
68
+ )
69
+
70
+ fun capabilities(context: Context): Map<String, Any?> {
71
+ val sensors = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
72
+ val counter = sensors?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
73
+ val detector = sensors?.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR) != null
74
+ return mapOf(
75
+ "hasStepCounter" to counter,
76
+ "hasStepDetector" to detector,
77
+ "supported" to (counter || detector),
78
+ "sdkInt" to Build.VERSION.SDK_INT,
79
+ "manufacturer" to Build.MANUFACTURER,
80
+ "model" to Build.MODEL
81
+ )
82
+ }
83
+
84
+ fun openAppSettings(context: Context): Boolean = runCatching {
85
+ val intent = Intent(
86
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
87
+ Uri.fromParts("package", context.packageName, null)
88
+ ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
89
+ context.startActivity(intent)
90
+ true
91
+ }.getOrDefault(false)
92
+ }
@@ -0,0 +1,53 @@
1
+ package com.steptrackerpro.util
2
+
3
+ import java.util.concurrent.CopyOnWriteArrayList
4
+
5
+ /**
6
+ * The service and the React module live in the same process, so events go
7
+ * through a plain in-memory bus rather than broadcasts. The module attaches on
8
+ * `initialize` and detaches on catalyst teardown; the service keeps running and
9
+ * writing to storage whether or not anyone is listening.
10
+ */
11
+ object StepEventBus {
12
+
13
+ /** Names match the JS side minus the `StepTrackerPro:` prefix. */
14
+ object Events {
15
+ const val STEPS_CHANGED = "stepsChanged"
16
+ const val GOAL_REACHED = "goalReached"
17
+ const val GOAL_PROGRESS_CHANGED = "goalProgressChanged"
18
+ const val TRACKING_STATE_CHANGED = "trackingStateChanged"
19
+ const val DAY_CHANGED = "dayChanged"
20
+ const val SYNC_COMPLETED = "syncCompleted"
21
+ const val ERROR = "error"
22
+ }
23
+
24
+ fun interface Subscriber {
25
+ fun onEvent(name: String, payload: Map<String, Any?>)
26
+ }
27
+
28
+ private val subscribers = CopyOnWriteArrayList<Subscriber>()
29
+
30
+ /**
31
+ * Identity-based: a subscriber added twice is delivered to twice. There is
32
+ * no useful dedupe to do here, because every caller passes a freshly
33
+ * SAM-converted lambda that is never equal to any other. The contract is
34
+ * that whoever subscribes also unsubscribes — the React module does so in
35
+ * `invalidate()`, and a subscriber that outlives its context is inert
36
+ * because `emit` checks for a live React instance first.
37
+ */
38
+ fun subscribe(subscriber: Subscriber) {
39
+ subscribers.add(subscriber)
40
+ }
41
+
42
+ fun unsubscribe(subscriber: Subscriber) {
43
+ subscribers.remove(subscriber)
44
+ }
45
+
46
+ fun emit(name: String, payload: Map<String, Any?>) {
47
+ subscribers.forEach { subscriber ->
48
+ runCatching { subscriber.onEvent(name, payload) }
49
+ }
50
+ }
51
+
52
+ fun hasSubscribers(): Boolean = subscribers.isNotEmpty()
53
+ }
@@ -0,0 +1,9 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="24dp"
3
+ android:height="24dp"
4
+ android:viewportWidth="24"
5
+ android:viewportHeight="24">
6
+ <path
7
+ android:fillColor="#FFFFFF"
8
+ android:pathData="M7.5,3C6.1,3 5,4.4 5,6.3c0,1.3 0.3,2.6 0.8,3.9C6.3,11.4 6.6,12.6 6.6,13.6c0,1 0.6,1.7 1.6,1.7h0.9c1,0 1.6,-0.7 1.6,-1.8c0,-1.2 -0.2,-2.4 -0.6,-3.6C9.6,8.6 9.3,7.3 9.3,6.1C9.3,4.3 8.6,3 7.5,3zM8.1,17c-0.9,0 -1.5,0.6 -1.5,1.5v1.1C6.6,20.5 7.3,21 8.2,21h1c0.9,0 1.5,-0.6 1.5,-1.5v-1c0,-0.9 -0.6,-1.5 -1.5,-1.5H8.1zM16.5,5c-1.1,0 -1.8,1.3 -1.8,3.1c0,1.2 -0.3,2.5 -0.7,3.8c-0.4,1.2 -0.6,2.4 -0.6,3.6c0,1.1 0.6,1.8 1.6,1.8h0.9c1,0 1.6,-0.7 1.6,-1.7c0,-1 0.3,-2.2 0.8,-3.4C18.7,10.9 19,9.6 19,8.3C19,6.4 17.9,5 16.5,5zM15.4,19c-0.9,0 -1.5,0.6 -1.5,1.5v0.4h4v-0.4c0,-0.9 -0.6,-1.5 -1.5,-1.5H15.4z" />
9
+ </vector>
@@ -0,0 +1,9 @@
1
+ <resources>
2
+ <string name="stp_channel_name">Step tracking</string>
3
+ <string name="stp_channel_description">Keeps counting your steps while the app is closed.</string>
4
+ <string name="stp_notification_text">%1$s km · %2$s kcal · %3$d%% of goal</string>
5
+ <string name="stp_action_open">Open</string>
6
+ <string name="stp_action_pause">Pause</string>
7
+ <string name="stp_action_resume">Resume</string>
8
+ <string name="stp_paused_text">Tracking paused</string>
9
+ </resources>