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