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,118 @@
|
|
|
1
|
+
package com.steptrackerpro.db
|
|
2
|
+
|
|
3
|
+
import androidx.room.Dao
|
|
4
|
+
import androidx.room.Insert
|
|
5
|
+
import androidx.room.OnConflictStrategy
|
|
6
|
+
import androidx.room.Query
|
|
7
|
+
import androidx.room.Transaction
|
|
8
|
+
|
|
9
|
+
@Dao
|
|
10
|
+
interface StepHistoryDao {
|
|
11
|
+
|
|
12
|
+
@Query("SELECT * FROM step_history WHERE date = :date LIMIT 1")
|
|
13
|
+
suspend fun findByDate(date: String): StepHistoryEntity?
|
|
14
|
+
|
|
15
|
+
@Query("SELECT * FROM step_history WHERE date BETWEEN :start AND :end ORDER BY date ASC")
|
|
16
|
+
suspend fun findRange(start: String, end: String): List<StepHistoryEntity>
|
|
17
|
+
|
|
18
|
+
@Query("SELECT COALESCE(SUM(steps), 0) FROM step_history WHERE date BETWEEN :start AND :end")
|
|
19
|
+
suspend fun sumSteps(start: String, end: String): Int
|
|
20
|
+
|
|
21
|
+
@Query("SELECT * FROM step_history WHERE synced = 0 ORDER BY date ASC LIMIT :limit")
|
|
22
|
+
suspend fun findUnsyncedHealth(limit: Int = 100): List<StepHistoryEntity>
|
|
23
|
+
|
|
24
|
+
@Query("SELECT * FROM step_history WHERE syncedRemote = 0 ORDER BY date ASC LIMIT :limit")
|
|
25
|
+
suspend fun findUnsyncedRemote(limit: Int = 100): List<StepHistoryEntity>
|
|
26
|
+
|
|
27
|
+
@Query("SELECT COUNT(*) FROM step_history WHERE synced = 0 OR syncedRemote = 0")
|
|
28
|
+
suspend fun countUnsynced(): Int
|
|
29
|
+
|
|
30
|
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
31
|
+
suspend fun insert(entity: StepHistoryEntity): Long
|
|
32
|
+
|
|
33
|
+
@Query(
|
|
34
|
+
"""
|
|
35
|
+
UPDATE step_history
|
|
36
|
+
SET steps = :steps, distance = :distance, calories = :calories,
|
|
37
|
+
synced = 0, syncedRemote = 0, updatedAt = :updatedAt
|
|
38
|
+
WHERE date = :date
|
|
39
|
+
"""
|
|
40
|
+
)
|
|
41
|
+
suspend fun update(
|
|
42
|
+
date: String,
|
|
43
|
+
steps: Int,
|
|
44
|
+
distance: Double,
|
|
45
|
+
calories: Double,
|
|
46
|
+
updatedAt: Long
|
|
47
|
+
): Int
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Upsert that never lets a re-anchored counter walk a day backwards: a
|
|
51
|
+
* lower step count for an existing date is ignored. Deliberate writes that
|
|
52
|
+
* must be allowed to lower a day - `resetToday()` - go through [replace].
|
|
53
|
+
*/
|
|
54
|
+
@Transaction
|
|
55
|
+
suspend fun upsert(entity: StepHistoryEntity) {
|
|
56
|
+
val existing = findByDate(entity.date)
|
|
57
|
+
if (existing == null) {
|
|
58
|
+
insert(entity)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (entity.steps < existing.steps) return
|
|
62
|
+
update(
|
|
63
|
+
date = entity.date,
|
|
64
|
+
steps = entity.steps,
|
|
65
|
+
distance = entity.distance,
|
|
66
|
+
calories = entity.calories,
|
|
67
|
+
updatedAt = System.currentTimeMillis()
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Query("UPDATE step_history SET synced = 1, updatedAt = :updatedAt WHERE date IN (:dates)")
|
|
72
|
+
suspend fun markSyncedHealth(dates: List<String>, updatedAt: Long = System.currentTimeMillis()): Int
|
|
73
|
+
|
|
74
|
+
@Query("UPDATE step_history SET syncedRemote = 1, updatedAt = :updatedAt WHERE date IN (:dates)")
|
|
75
|
+
suspend fun markSyncedRemote(dates: List<String>, updatedAt: Long = System.currentTimeMillis()): Int
|
|
76
|
+
|
|
77
|
+
/** Overwrites a day outright, downwards included. */
|
|
78
|
+
@Transaction
|
|
79
|
+
suspend fun replace(entity: StepHistoryEntity) {
|
|
80
|
+
val existing = findByDate(entity.date)
|
|
81
|
+
if (existing == null) {
|
|
82
|
+
insert(entity)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
update(
|
|
86
|
+
date = entity.date,
|
|
87
|
+
steps = entity.steps,
|
|
88
|
+
distance = entity.distance,
|
|
89
|
+
calories = entity.calories,
|
|
90
|
+
updatedAt = System.currentTimeMillis()
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@Query("DELETE FROM step_history WHERE date < :cutoff")
|
|
95
|
+
suspend fun deleteOlderThan(cutoff: String): Int
|
|
96
|
+
|
|
97
|
+
@Query("DELETE FROM step_history")
|
|
98
|
+
suspend fun deleteAll()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Dao
|
|
102
|
+
interface DailySummaryDao {
|
|
103
|
+
|
|
104
|
+
@Query("SELECT * FROM daily_summary WHERE date = :date LIMIT 1")
|
|
105
|
+
suspend fun findByDate(date: String): DailySummaryEntity?
|
|
106
|
+
|
|
107
|
+
@Query("SELECT * FROM daily_summary WHERE date BETWEEN :start AND :end ORDER BY date ASC")
|
|
108
|
+
suspend fun findRange(start: String, end: String): List<DailySummaryEntity>
|
|
109
|
+
|
|
110
|
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
111
|
+
suspend fun upsert(entity: DailySummaryEntity)
|
|
112
|
+
|
|
113
|
+
@Query("DELETE FROM daily_summary WHERE date < :cutoff")
|
|
114
|
+
suspend fun deleteOlderThan(cutoff: String): Int
|
|
115
|
+
|
|
116
|
+
@Query("DELETE FROM daily_summary")
|
|
117
|
+
suspend fun deleteAll()
|
|
118
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package com.steptrackerpro.db
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.room.Database
|
|
5
|
+
import androidx.room.Room
|
|
6
|
+
import androidx.room.RoomDatabase
|
|
7
|
+
import androidx.room.migration.Migration
|
|
8
|
+
import androidx.sqlite.db.SupportSQLiteDatabase
|
|
9
|
+
|
|
10
|
+
@Database(
|
|
11
|
+
entities = [StepHistoryEntity::class, DailySummaryEntity::class],
|
|
12
|
+
version = 2,
|
|
13
|
+
exportSchema = true
|
|
14
|
+
)
|
|
15
|
+
abstract class StepDatabase : RoomDatabase() {
|
|
16
|
+
|
|
17
|
+
abstract fun stepHistoryDao(): StepHistoryDao
|
|
18
|
+
abstract fun dailySummaryDao(): DailySummaryDao
|
|
19
|
+
|
|
20
|
+
companion object {
|
|
21
|
+
private const val NAME = "step_tracker_pro.db"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Health Connect and the remote endpoint used to share the `synced`
|
|
25
|
+
* flag, so whichever synced first hid the row from the other. Existing
|
|
26
|
+
* rows keep `synced` as the Health Connect flag and start out pending
|
|
27
|
+
* for the remote endpoint, which at worst re-uploads days the endpoint
|
|
28
|
+
* already has - the upload is keyed by date and is idempotent.
|
|
29
|
+
*/
|
|
30
|
+
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
|
31
|
+
override fun migrate(db: SupportSQLiteDatabase) {
|
|
32
|
+
db.execSQL(
|
|
33
|
+
"ALTER TABLE step_history ADD COLUMN syncedRemote INTEGER NOT NULL DEFAULT 0"
|
|
34
|
+
)
|
|
35
|
+
db.execSQL(
|
|
36
|
+
"CREATE INDEX IF NOT EXISTS index_step_history_syncedRemote " +
|
|
37
|
+
"ON step_history (syncedRemote)"
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Volatile
|
|
43
|
+
private var instance: StepDatabase? = null
|
|
44
|
+
|
|
45
|
+
fun get(context: Context): StepDatabase =
|
|
46
|
+
instance ?: synchronized(this) {
|
|
47
|
+
instance ?: Room.databaseBuilder(
|
|
48
|
+
context.applicationContext,
|
|
49
|
+
StepDatabase::class.java,
|
|
50
|
+
NAME
|
|
51
|
+
)
|
|
52
|
+
.addMigrations(MIGRATION_1_2)
|
|
53
|
+
// Counter state lives in SharedPreferences, so a corrupt or
|
|
54
|
+
// unmigratable history file costs history, never the live count.
|
|
55
|
+
.fallbackToDestructiveMigrationOnDowngrade()
|
|
56
|
+
.build()
|
|
57
|
+
.also { instance = it }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
package com.steptrackerpro.db
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.room.withTransaction
|
|
5
|
+
import com.steptrackerpro.core.DateKeys
|
|
6
|
+
import com.steptrackerpro.core.DayTotals
|
|
7
|
+
import com.steptrackerpro.core.RangeStats
|
|
8
|
+
import com.steptrackerpro.core.SyncTarget
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Everything the rest of the package knows about storage. Callers deal in
|
|
12
|
+
* [DayTotals], never in Room entities.
|
|
13
|
+
*/
|
|
14
|
+
class StepRepository(context: Context) {
|
|
15
|
+
|
|
16
|
+
private val db = StepDatabase.get(context)
|
|
17
|
+
private val history = db.stepHistoryDao()
|
|
18
|
+
private val summaries = db.dailySummaryDao()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Writes a day, refusing to lower an existing count. Both tables move in one
|
|
22
|
+
* transaction so a process death cannot leave them disagreeing.
|
|
23
|
+
*/
|
|
24
|
+
suspend fun saveDay(totals: DayTotals) = db.withTransaction {
|
|
25
|
+
history.upsert(totals.toEntity())
|
|
26
|
+
summaries.upsert(totals.toSummary())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Writes a day even when that lowers it. Only for deliberate, user-initiated
|
|
31
|
+
* writes - `resetToday()` - where [saveDay]'s guard would silently keep the
|
|
32
|
+
* old total and leave history disagreeing with the live counter forever.
|
|
33
|
+
*/
|
|
34
|
+
suspend fun overwriteDay(totals: DayTotals) = db.withTransaction {
|
|
35
|
+
history.replace(totals.toEntity())
|
|
36
|
+
summaries.upsert(totals.toSummary())
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
suspend fun getDay(date: String): DayTotals =
|
|
40
|
+
history.findByDate(date)?.toTotals()
|
|
41
|
+
?: DayTotals(date, 0, 0.0, 0.0, synced = true, syncedRemote = true)
|
|
42
|
+
|
|
43
|
+
/** Zero-filled, ascending, inclusive of both ends. */
|
|
44
|
+
suspend fun getRange(start: String, end: String): List<DayTotals> {
|
|
45
|
+
val stored = history.findRange(start, end).associateBy { it.date }
|
|
46
|
+
return DateKeys.rangeOf(start, end).map { key ->
|
|
47
|
+
stored[key]?.toTotals()
|
|
48
|
+
?: DayTotals(key, 0, 0.0, 0.0, synced = true, syncedRemote = true)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param live today's in-memory totals, which are newer than anything
|
|
54
|
+
* committed to the database. Pass null when today is outside the range.
|
|
55
|
+
*/
|
|
56
|
+
suspend fun stats(
|
|
57
|
+
start: String,
|
|
58
|
+
end: String,
|
|
59
|
+
goal: Int?,
|
|
60
|
+
live: DayTotals?
|
|
61
|
+
): RangeStats {
|
|
62
|
+
// The live counter is authoritative for today: it is newer than anything
|
|
63
|
+
// committed, and after a reset it is legitimately lower than the stored
|
|
64
|
+
// row. Preferring the larger of the two would resurrect the old total.
|
|
65
|
+
val days = getRange(start, end).map { day ->
|
|
66
|
+
if (live != null && day.date == live.date) live else day
|
|
67
|
+
}
|
|
68
|
+
val totalSteps = days.sumOf { it.steps }
|
|
69
|
+
val totalDistance = days.sumOf { it.distance }
|
|
70
|
+
val totalCalories = days.sumOf { it.calories }
|
|
71
|
+
val activeDays = days.count { it.steps > 0 }
|
|
72
|
+
|
|
73
|
+
// Average over elapsed days only, so a partially finished month is not
|
|
74
|
+
// diluted by days that have not happened yet.
|
|
75
|
+
val today = DateKeys.today()
|
|
76
|
+
val elapsed = if (today in start..end) {
|
|
77
|
+
DateKeys.daysBetween(start, if (today < end) today else end)
|
|
78
|
+
} else {
|
|
79
|
+
days.size
|
|
80
|
+
}.coerceAtLeast(1)
|
|
81
|
+
|
|
82
|
+
return RangeStats(
|
|
83
|
+
startDate = start,
|
|
84
|
+
endDate = end,
|
|
85
|
+
totalSteps = totalSteps,
|
|
86
|
+
totalDistance = totalDistance,
|
|
87
|
+
totalCalories = totalCalories,
|
|
88
|
+
averageSteps = totalSteps / elapsed,
|
|
89
|
+
activeDays = activeDays,
|
|
90
|
+
bestDay = days.maxByOrNull { it.steps }?.takeIf { it.steps > 0 },
|
|
91
|
+
days = days,
|
|
92
|
+
goal = goal,
|
|
93
|
+
goalProgress = goal?.takeIf { it > 0 }?.let {
|
|
94
|
+
(totalSteps.toDouble() / it).coerceIn(0.0, 1.0)
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
suspend fun sumSteps(start: String, end: String): Int = history.sumSteps(start, end)
|
|
100
|
+
|
|
101
|
+
suspend fun unsynced(target: SyncTarget, limit: Int = 100): List<DayTotals> = when (target) {
|
|
102
|
+
SyncTarget.HEALTH_CONNECT -> history.findUnsyncedHealth(limit)
|
|
103
|
+
SyncTarget.REMOTE -> history.findUnsyncedRemote(limit)
|
|
104
|
+
}.map { it.toTotals() }
|
|
105
|
+
|
|
106
|
+
suspend fun countUnsynced(): Int = history.countUnsynced()
|
|
107
|
+
|
|
108
|
+
suspend fun markSynced(target: SyncTarget, dates: List<String>) {
|
|
109
|
+
if (dates.isEmpty()) return
|
|
110
|
+
when (target) {
|
|
111
|
+
SyncTarget.HEALTH_CONNECT -> history.markSyncedHealth(dates)
|
|
112
|
+
SyncTarget.REMOTE -> history.markSyncedRemote(dates)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Drops rows older than the retention window. Returns rows removed. */
|
|
117
|
+
suspend fun prune(retentionDays: Int): Int {
|
|
118
|
+
val cutoff = DateKeys.minusDays(DateKeys.today(), retentionDays.coerceAtLeast(1))
|
|
119
|
+
summaries.deleteOlderThan(cutoff)
|
|
120
|
+
return history.deleteOlderThan(cutoff)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
suspend fun clear() {
|
|
124
|
+
history.deleteAll()
|
|
125
|
+
summaries.deleteAll()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private fun StepHistoryEntity.toTotals() =
|
|
129
|
+
DayTotals(date, steps, distance, calories, syncedHealth, syncedRemote)
|
|
130
|
+
|
|
131
|
+
private fun DayTotals.toEntity() = StepHistoryEntity(
|
|
132
|
+
date = date,
|
|
133
|
+
steps = steps,
|
|
134
|
+
distance = distance,
|
|
135
|
+
calories = calories,
|
|
136
|
+
syncedHealth = synced,
|
|
137
|
+
syncedRemote = syncedRemote
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
private fun DayTotals.toSummary() = DailySummaryEntity(
|
|
141
|
+
date = date,
|
|
142
|
+
totalSteps = steps,
|
|
143
|
+
totalDistance = distance,
|
|
144
|
+
totalCalories = calories
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
package com.steptrackerpro.health
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import androidx.health.connect.client.HealthConnectClient
|
|
6
|
+
import androidx.health.connect.client.PermissionController
|
|
7
|
+
import androidx.health.connect.client.aggregate.AggregationResultGroupedByPeriod
|
|
8
|
+
import androidx.health.connect.client.permission.HealthPermission
|
|
9
|
+
import androidx.health.connect.client.records.DistanceRecord
|
|
10
|
+
import androidx.health.connect.client.records.StepsRecord
|
|
11
|
+
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
|
|
12
|
+
import androidx.health.connect.client.records.metadata.Device
|
|
13
|
+
import androidx.health.connect.client.records.metadata.Metadata
|
|
14
|
+
import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
|
|
15
|
+
import androidx.health.connect.client.time.TimeRangeFilter
|
|
16
|
+
import androidx.health.connect.client.units.Energy
|
|
17
|
+
import androidx.health.connect.client.units.Length
|
|
18
|
+
import com.steptrackerpro.core.DateKeys
|
|
19
|
+
import com.steptrackerpro.core.DayTotals
|
|
20
|
+
import com.steptrackerpro.core.StepStateStore
|
|
21
|
+
import java.time.Instant
|
|
22
|
+
import java.time.LocalDateTime
|
|
23
|
+
import java.time.Period
|
|
24
|
+
import java.time.ZoneId
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Health Connect read/write.
|
|
28
|
+
*
|
|
29
|
+
* Writes are idempotent: every record carries a stable `clientRecordId` of the
|
|
30
|
+
* form `stp-<type>-<date>`, so re-syncing a day replaces the previous record
|
|
31
|
+
* instead of stacking duplicates. That is what makes "sync today's total every
|
|
32
|
+
* 30 minutes" safe. Health Connect keeps the higher `clientRecordVersion` on a
|
|
33
|
+
* collision, so that value must never go backwards - see
|
|
34
|
+
* [StepStateStore.nextHealthRecordVersion].
|
|
35
|
+
*/
|
|
36
|
+
class HealthConnectManager(
|
|
37
|
+
private val context: Context,
|
|
38
|
+
private val state: StepStateStore
|
|
39
|
+
) {
|
|
40
|
+
|
|
41
|
+
enum class Availability { AVAILABLE, UPDATE_REQUIRED, UNAVAILABLE }
|
|
42
|
+
|
|
43
|
+
@Volatile
|
|
44
|
+
private var cachedClient: HealthConnectClient? = null
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Only a *successful* client is cached. This used to be a `by lazy`, which
|
|
48
|
+
* on a process-wide singleton meant that one call made before Health
|
|
49
|
+
* Connect was installed pinned `null` for the life of the process: the user
|
|
50
|
+
* could install the provider and grant everything, and every operation
|
|
51
|
+
* would still fail until the app was killed.
|
|
52
|
+
*/
|
|
53
|
+
private fun client(): HealthConnectClient? {
|
|
54
|
+
cachedClient?.let { return it }
|
|
55
|
+
if (availability() != Availability.AVAILABLE) return null
|
|
56
|
+
return runCatching { HealthConnectClient.getOrCreate(context) }
|
|
57
|
+
.getOrNull()
|
|
58
|
+
?.also { cachedClient = it }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fun availability(): Availability = when (HealthConnectClient.getSdkStatus(context)) {
|
|
62
|
+
HealthConnectClient.SDK_AVAILABLE -> Availability.AVAILABLE
|
|
63
|
+
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> Availability.UPDATE_REQUIRED
|
|
64
|
+
else -> Availability.UNAVAILABLE
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
suspend fun grantedPermissions(): Set<String> =
|
|
68
|
+
client()?.permissionController?.getGrantedPermissions() ?: emptySet()
|
|
69
|
+
|
|
70
|
+
suspend fun hasAllPermissions(): Boolean =
|
|
71
|
+
grantedPermissions().containsAll(PERMISSIONS)
|
|
72
|
+
|
|
73
|
+
suspend fun status(): Map<String, Any?> {
|
|
74
|
+
val availability = availability()
|
|
75
|
+
val granted = if (availability == Availability.AVAILABLE) grantedPermissions() else emptySet()
|
|
76
|
+
return mapOf(
|
|
77
|
+
"available" to (availability == Availability.AVAILABLE),
|
|
78
|
+
"requiresUpdate" to (availability == Availability.UPDATE_REQUIRED),
|
|
79
|
+
"granted" to granted.containsAll(PERMISSIONS),
|
|
80
|
+
"grantedPermissions" to granted.toList()
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Daily step totals from every source Health Connect knows about. */
|
|
85
|
+
suspend fun readDailySteps(start: Instant, end: Instant): List<DayTotals> {
|
|
86
|
+
val hc = client() ?: return emptyList()
|
|
87
|
+
val zone = ZoneId.systemDefault()
|
|
88
|
+
val request = AggregateGroupByPeriodRequest(
|
|
89
|
+
metrics = setOf(
|
|
90
|
+
StepsRecord.COUNT_TOTAL,
|
|
91
|
+
DistanceRecord.DISTANCE_TOTAL,
|
|
92
|
+
TotalCaloriesBurnedRecord.ENERGY_TOTAL
|
|
93
|
+
),
|
|
94
|
+
timeRangeFilter = TimeRangeFilter.between(
|
|
95
|
+
LocalDateTime.ofInstant(start, zone),
|
|
96
|
+
LocalDateTime.ofInstant(end, zone)
|
|
97
|
+
),
|
|
98
|
+
timeRangeSlicer = Period.ofDays(1)
|
|
99
|
+
)
|
|
100
|
+
return runCatching { hc.aggregateGroupByPeriod(request) }
|
|
101
|
+
.getOrDefault(emptyList<AggregationResultGroupedByPeriod>())
|
|
102
|
+
.map { group ->
|
|
103
|
+
DayTotals(
|
|
104
|
+
date = DateKeys.format(group.startTime.toLocalDate()),
|
|
105
|
+
steps = (group.result[StepsRecord.COUNT_TOTAL] ?: 0L).toInt(),
|
|
106
|
+
distance = group.result[DistanceRecord.DISTANCE_TOTAL]?.inMeters ?: 0.0,
|
|
107
|
+
calories = group.result[TotalCaloriesBurnedRecord.ENERGY_TOTAL]
|
|
108
|
+
?.inKilocalories ?: 0.0,
|
|
109
|
+
synced = true
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Upserts one day. Returns false when the client is missing, permissions
|
|
116
|
+
* were revoked, or the provider rejected the write.
|
|
117
|
+
*/
|
|
118
|
+
suspend fun writeDay(totals: DayTotals): Boolean {
|
|
119
|
+
val hc = client() ?: return false
|
|
120
|
+
if (totals.steps <= 0) return true
|
|
121
|
+
if (!hasAllPermissions()) return false
|
|
122
|
+
|
|
123
|
+
val zone = ZoneId.systemDefault()
|
|
124
|
+
val start = DateKeys.startOfDayInstant(totals.date)
|
|
125
|
+
val rawEnd = DateKeys.endOfDayInstant(totals.date)
|
|
126
|
+
// An open day must not be written with an end time in the future.
|
|
127
|
+
val end = if (rawEnd.isAfter(Instant.now())) Instant.now() else rawEnd
|
|
128
|
+
if (!end.isAfter(start)) return true
|
|
129
|
+
|
|
130
|
+
val startOffset = zone.rules.getOffset(start)
|
|
131
|
+
val endOffset = zone.rules.getOffset(end)
|
|
132
|
+
// Health Connect resolves same-id collisions by keeping the higher
|
|
133
|
+
// version, so this has to be monotonic. Using the wall clock meant that
|
|
134
|
+
// after the user set the clock back, every write was silently discarded
|
|
135
|
+
// by the provider while insertRecords still reported success.
|
|
136
|
+
val version = state.nextHealthRecordVersion()
|
|
137
|
+
val device = Device(type = Device.TYPE_PHONE)
|
|
138
|
+
|
|
139
|
+
val records = listOf(
|
|
140
|
+
StepsRecord(
|
|
141
|
+
count = totals.steps.toLong(),
|
|
142
|
+
startTime = start,
|
|
143
|
+
endTime = end,
|
|
144
|
+
startZoneOffset = startOffset,
|
|
145
|
+
endZoneOffset = endOffset,
|
|
146
|
+
metadata = Metadata.autoRecorded(
|
|
147
|
+
device = device,
|
|
148
|
+
clientRecordId = "stp-steps-${totals.date}",
|
|
149
|
+
clientRecordVersion = version
|
|
150
|
+
)
|
|
151
|
+
),
|
|
152
|
+
DistanceRecord(
|
|
153
|
+
distance = Length.meters(totals.distance),
|
|
154
|
+
startTime = start,
|
|
155
|
+
endTime = end,
|
|
156
|
+
startZoneOffset = startOffset,
|
|
157
|
+
endZoneOffset = endOffset,
|
|
158
|
+
metadata = Metadata.autoRecorded(
|
|
159
|
+
device = device,
|
|
160
|
+
clientRecordId = "stp-distance-${totals.date}",
|
|
161
|
+
clientRecordVersion = version
|
|
162
|
+
)
|
|
163
|
+
),
|
|
164
|
+
TotalCaloriesBurnedRecord(
|
|
165
|
+
energy = Energy.kilocalories(totals.calories),
|
|
166
|
+
startTime = start,
|
|
167
|
+
endTime = end,
|
|
168
|
+
startZoneOffset = startOffset,
|
|
169
|
+
endZoneOffset = endOffset,
|
|
170
|
+
metadata = Metadata.autoRecorded(
|
|
171
|
+
device = device,
|
|
172
|
+
clientRecordId = "stp-calories-${totals.date}",
|
|
173
|
+
clientRecordVersion = version
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return runCatching { hc.insertRecords(records); true }.getOrDefault(false)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
fun settingsIntent(): Intent =
|
|
182
|
+
Intent(HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS)
|
|
183
|
+
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
184
|
+
|
|
185
|
+
companion object {
|
|
186
|
+
val PERMISSIONS: Set<String> = setOf(
|
|
187
|
+
HealthPermission.getReadPermission(StepsRecord::class),
|
|
188
|
+
HealthPermission.getWritePermission(StepsRecord::class),
|
|
189
|
+
HealthPermission.getReadPermission(DistanceRecord::class),
|
|
190
|
+
HealthPermission.getWritePermission(DistanceRecord::class),
|
|
191
|
+
HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
|
|
192
|
+
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
fun permissionContract() =
|
|
196
|
+
PermissionController.createRequestPermissionResultContract()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package com.steptrackerpro.health
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.os.Bundle
|
|
5
|
+
import androidx.activity.ComponentActivity
|
|
6
|
+
import androidx.activity.result.ActivityResultLauncher
|
|
7
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Health Connect permissions can only be requested through an
|
|
11
|
+
* ActivityResultContract, which needs a real Activity. The React module starts
|
|
12
|
+
* this transparent activity, which requests the grants and hands the result
|
|
13
|
+
* back through [Callbacks].
|
|
14
|
+
*
|
|
15
|
+
* The manifest entry must not carry `android:noHistory`: that finishes the
|
|
16
|
+
* activity the moment the Health Connect screen covers it, so the contract can
|
|
17
|
+
* never deliver and every request resolves with the pre-grant status.
|
|
18
|
+
*/
|
|
19
|
+
class HealthPermissionActivity : ComponentActivity() {
|
|
20
|
+
|
|
21
|
+
private lateinit var launcher: ActivityResultLauncher<Set<String>>
|
|
22
|
+
|
|
23
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
24
|
+
super.onCreate(savedInstanceState)
|
|
25
|
+
launcher = registerForActivityResult(HealthConnectManager.permissionContract()) { granted ->
|
|
26
|
+
Callbacks.deliver(granted)
|
|
27
|
+
setResult(Activity.RESULT_OK)
|
|
28
|
+
finish()
|
|
29
|
+
overridePendingTransition(0, 0)
|
|
30
|
+
}
|
|
31
|
+
// Only launch on a fresh start. After a configuration change or a
|
|
32
|
+
// process restart the contract is already in flight and relaunching
|
|
33
|
+
// would stack a second permission screen.
|
|
34
|
+
if (savedInstanceState == null) {
|
|
35
|
+
runCatching { launcher.launch(HealthConnectManager.PERMISSIONS) }
|
|
36
|
+
.onFailure {
|
|
37
|
+
Callbacks.deliver(emptySet())
|
|
38
|
+
finish()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override fun onDestroy() {
|
|
44
|
+
super.onDestroy()
|
|
45
|
+
// Guard against the activity being torn down before the contract
|
|
46
|
+
// returns (e.g. the provider app was killed). isFinishing is false when
|
|
47
|
+
// the system is only recreating us, where the result is still coming.
|
|
48
|
+
if (isFinishing) Callbacks.deliverIfPending()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
object Callbacks {
|
|
52
|
+
fun interface Listener {
|
|
53
|
+
fun onResult(granted: Set<String>)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private val listeners = CopyOnWriteArrayList<Listener>()
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A result that arrived before anyone was waiting for it. The caller
|
|
60
|
+
* registers its listener only after `startActivity` succeeds, so that a
|
|
61
|
+
* failed launch cannot leave a listener armed to settle a promise that
|
|
62
|
+
* was already rejected; this closes the window that opens up.
|
|
63
|
+
*/
|
|
64
|
+
private var latched: Set<String>? = null
|
|
65
|
+
|
|
66
|
+
/** Call before launching, so a stale result cannot be picked up. */
|
|
67
|
+
@Synchronized
|
|
68
|
+
fun expect() {
|
|
69
|
+
latched = null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Synchronized
|
|
73
|
+
fun await(listener: Listener) {
|
|
74
|
+
val ready = latched
|
|
75
|
+
if (ready != null) {
|
|
76
|
+
latched = null
|
|
77
|
+
runCatching { listener.onResult(ready) }
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
listeners.add(listener)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Synchronized
|
|
84
|
+
fun deliver(granted: Set<String>) {
|
|
85
|
+
if (listeners.isEmpty()) {
|
|
86
|
+
latched = granted
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
val snapshot = listeners.toList()
|
|
90
|
+
listeners.clear()
|
|
91
|
+
snapshot.forEach { runCatching { it.onResult(granted) } }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@Synchronized
|
|
95
|
+
fun deliverIfPending() {
|
|
96
|
+
if (listeners.isNotEmpty()) deliver(emptySet())
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package com.steptrackerpro.service
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import com.steptrackerpro.core.StepTrackerCore
|
|
8
|
+
import com.steptrackerpro.util.PermissionHelper
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Brings tracking back after a reboot or an app update.
|
|
12
|
+
*
|
|
13
|
+
* The receiver only restarts when the user had tracking on before, so an app
|
|
14
|
+
* that has never called `startTracking()` never spawns a service at boot. The
|
|
15
|
+
* counter itself is reconciled inside the engine — the receiver's job is just
|
|
16
|
+
* to get the service running again so the notification and the database writes
|
|
17
|
+
* resume.
|
|
18
|
+
*/
|
|
19
|
+
class BootReceiver : BroadcastReceiver() {
|
|
20
|
+
|
|
21
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
22
|
+
val action = intent.action ?: return
|
|
23
|
+
if (action !in HANDLED_ACTIONS) return
|
|
24
|
+
|
|
25
|
+
val core = StepTrackerCore.get(context)
|
|
26
|
+
if (!core.isInitialized()) return
|
|
27
|
+
if (!core.config().autoStartOnBoot) return
|
|
28
|
+
if (!core.state.shouldAutoStart) return
|
|
29
|
+
|
|
30
|
+
if (!PermissionHelper.canStartTracking(context)) {
|
|
31
|
+
Log.w(TAG, "Skipping boot restart: ACTIVITY_RECOGNITION not granted")
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Re-anchor against the new boot before the first sample arrives.
|
|
36
|
+
core.engine.reconcile()
|
|
37
|
+
|
|
38
|
+
runCatching { ServiceCommands.start(context, fromBoot = true) }
|
|
39
|
+
.onFailure { Log.e(TAG, "Boot restart failed", it) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
companion object {
|
|
43
|
+
private const val TAG = "StepTrackerBoot"
|
|
44
|
+
|
|
45
|
+
// LOCKED_BOOT_COMPLETED is deliberately absent: it is only delivered to
|
|
46
|
+
// direct-boot-aware components, and the counter state lives in
|
|
47
|
+
// credential-encrypted storage that is not readable before unlock.
|
|
48
|
+
private val HANDLED_ACTIONS = setOf(
|
|
49
|
+
Intent.ACTION_BOOT_COMPLETED,
|
|
50
|
+
Intent.ACTION_MY_PACKAGE_REPLACED,
|
|
51
|
+
"android.intent.action.QUICKBOOT_POWERON"
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package com.steptrackerpro.service
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
|
|
7
|
+
/** Routes the notification's Pause/Resume buttons back into the service. */
|
|
8
|
+
class NotificationActionReceiver : BroadcastReceiver() {
|
|
9
|
+
|
|
10
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
11
|
+
when (intent.action) {
|
|
12
|
+
ServiceCommands.ACTION_PAUSE,
|
|
13
|
+
ServiceCommands.ACTION_RESUME,
|
|
14
|
+
ServiceCommands.ACTION_STOP -> ServiceCommands.send(context, intent.action!!)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|