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