Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,44 @@
Pedometer
=========

WIP
Personal Android pedometer using phone-recorded steps from Health Connect.

## Debug builds

Debug installs as `dev.sjaramillo.pedometer.debug`, alongside release
(`dev.sjaramillo.pedometer`). Both use the launcher label **Pedometer**; the debug icon adds a small **D**
behind the right foot. The existing debug signing configuration is unchanged.

Debug starts with real data. Open the toolbar overflow menu, then **Debug → Use
fake steps**, to enable sample data without Health Connect permissions or a
step-counter sensor. The selection survives relaunch. A status message on Home
identifies fake mode. Release has no debug menu or fake provider.

Fake mode refreshes the last 30 days, including today, with a repeating sequence
of 1,500, 4,500, 7,500, 10,500, and 14,000 steps indexed by epoch day. Counts for
a given date are deterministic. Home and Stats observe the shared Room cache;
CSV import/export uses that same cache.

**Fake data can overwrite real or imported debug history in that 30-day window.**
Older history remains. Disabling fake mode resumes Health Connect; sample counts
can remain cached until real data replaces them. If Health Connect is unavailable
or permission is missing, the app retains cached counts and shows its status.

## Verification

Use JDK 25 and Android SDK 37, then run from the repository root:

```sh
./gradlew build
./gradlew ktlintCheck
./gradlew :app:testDebugUnitTest --tests 'dev.sjaramillo.pedometer.data.StepsCsvTest'
```

On an emulator, check fake mode without granting Health Connect access, relaunch
persistence, mode switching, charts, Stats, and CSV import/export. Check that
release has no debug menu and installs alongside debug when a signed release is
available. These interactive checks require a device or emulator; neither was
available in the implementation environment. Release signing remains unfinished.

# Acknowledgements

Expand Down
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ android {

buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
isMinifyEnabled = false
}
getByName("release") {
Expand Down Expand Up @@ -102,6 +103,7 @@ dependencies {

// Unit Tests
testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
}

ksp {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package dev.sjaramillo.pedometer.data

import android.content.SharedPreferences
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton

@Singleton
class DebugStepsProvider @Inject constructor(
private val preferences: SharedPreferences,
private val phoneProvider: Provider<PhoneStepsDataSource>,
private val fakeProvider: FakeStepsProvider,
) : StepsProvider {
override val isFake: Boolean get() = preferences.getBoolean(FAKE_STEPS_KEY, false)

fun setFakeSteps(enabled: Boolean) {
preferences.edit().putBoolean(FAKE_STEPS_KEY, enabled).apply()
}

private fun selected(): StepsProvider = if (isFake) fakeProvider else phoneProvider.get()

override suspend fun access(): StepsAccess = selected().access()

override suspend fun readDailySteps(
start: LocalDate,
end: LocalDate,
): Map<LocalDate, Long> = selected().readDailySteps(start, end)

private companion object {
const val FAKE_STEPS_KEY = "debug_fake_steps"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package dev.sjaramillo.pedometer.data

import java.time.LocalDate
import javax.inject.Inject

class FakeStepsProvider @Inject constructor() : StepsProvider {
override val isFake = true

override suspend fun access(): StepsAccess = StepsAccess.Available

override suspend fun readDailySteps(
start: LocalDate,
end: LocalDate,
): Map<LocalDate, Long> =
if (end < start) {
emptyMap()
} else {
generateSequence(start) { it.plusDays(1).takeIf { day -> day <= end } }
.associateWith { SAMPLE_COUNTS[Math.floorMod(it.toEpochDay(), SAMPLE_COUNTS.size.toLong()).toInt()] }
}

private companion object {
val SAMPLE_COUNTS = longArrayOf(1500, 4500, 7500, 10500, 14000)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package dev.sjaramillo.pedometer.di.module

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dev.sjaramillo.pedometer.data.DebugStepsProvider
import dev.sjaramillo.pedometer.data.StepsProvider

@Module
@InstallIn(SingletonComponent::class)
abstract class StepsProviderModule {
@Binds
abstract fun bindStepsProvider(provider: DebugStepsProvider): StepsProvider
}
35 changes: 35 additions & 0 deletions app/src/debug/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package dev.sjaramillo.pedometer.ui

import android.view.Menu
import android.view.MenuItem
import dev.sjaramillo.pedometer.R
import dev.sjaramillo.pedometer.data.DebugStepsProvider
import dev.sjaramillo.pedometer.data.HealthConnectSyncCoordinator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject

class DebugStepsMenu @Inject constructor(
private val provider: DebugStepsProvider,
private val coordinator: HealthConnectSyncCoordinator,
) {
fun addTo(menu: Menu) {
menu.addSubMenu(R.string.debug_menu).add(Menu.NONE, R.id.action_fake_steps, Menu.NONE, R.string.use_fake_steps).apply {
isCheckable = true
isChecked = provider.isFake
}
}

fun select(
item: MenuItem,
scope: CoroutineScope,
invalidate: () -> Unit,
): Boolean {
if (item.itemId != R.id.action_fake_steps) return false
scope.launch {
coordinator.refreshAfter { provider.setFakeSteps(!provider.isFake) }
invalidate()
}
return true
}
}
57 changes: 57 additions & 0 deletions app/src/debug/res/drawable/ic_launcher_foreground.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="48"
android:viewportHeight="48">

<group
android:pivotX="24"
android:pivotY="24"
android:scaleX="0.68"
android:scaleY="0.68">
<!-- Small outlined D behind the heel of the right foot, matching the original linework. -->
<path
android:fillColor="#00000000"
android:pathData="M13,33.5L13,41L15,41C19.6,41 19.6,33.5 15,33.5Z"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.25" />
<path
android:fillColor="#00000000"
android:pathData="M40.48,15.76c0,-5.58 -5.85,-11.26 -11.6,-11.26A1.87,1.87 0,0 0,27,6.18L25.48,22.05a15.16,15.16 0,0 1,-5.26,-4.33a7.83,7.83 0,0 1,0.74,-0.58c0.57,-0.39 -1.08,-3.78 -1.77,-4.61s-1.91,0.67 -6.5,3.48S7,21.81 7.75,22.65s3.61,-0.41 5.18,-1.35s4.46,-0.07 4.73,-0.05c0.08,0 0.19,-0.15 0.33,-0.39c1.61,1.83 4.15,4.18 7.12,5l-1,10.59a8.16,8.16 0,0 1,-1.11,-0.13c-0.68,-0.13 -1.9,3.44 -2,4.51S22.77,41.71 28,43s7.84,0.09 8.18,-1.2c0.28,-1 -2.84,-2.26 -4.61,-2.71s-3.21,-3.1 -3.39,-3.31c0,0 -0.15,0 -0.3,0l0.94,-9.66C36.35,25.37 40.48,20.44 40.48,15.76ZM30.55,8.48c3.17,0.94 6.2,4.29 6.2,7.28c0,2.79 -2.58,5.73 -7.55,6.51Z"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.25" />
<path
android:fillColor="#00000000"
android:pathData="M20.22,17.72L17.99,20.86"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.25" />
<path
android:fillColor="#00000000"
android:pathData="M24.07,36.45L27.89,35.74"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.25" />
<path
android:fillColor="#00000000"
android:pathData="M25.48,22.05L25.11,25.86"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.25" />
<path
android:fillColor="#00000000"
android:pathData="M29.2,22.27L28.83,26.08"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.25" />
</group>
</vector>
6 changes: 6 additions & 0 deletions app/src/debug/res/values/debug.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="debug_menu">Debug</string>
<string name="use_fake_steps">Use fake steps</string>
<item name="action_fake_steps" type="id" />
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface DailyStepsDao {
@Query("SELECT * FROM daily_steps WHERE day>0 ORDER BY day")
suspend fun getAll(): List<DailySteps>

@Query("SELECT steps FROM daily_steps WHERE day=:day")
suspend fun getSteps(day: Long): Long?

@Query("SELECT * FROM daily_steps WHERE day>0 ORDER BY day DESC LIMIT :num")
suspend fun getLastEntries(num: Int): List<DailySteps>
@Query("SELECT * FROM daily_steps WHERE day>0 ORDER BY day")
fun observeAll(): Flow<List<DailySteps>>

@Query("SELECT * FROM daily_steps WHERE day >= :start AND day <= :end")
suspend fun getEntriesInRange(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import javax.inject.Inject

class HealthConnectStepCacheSynchronizer @Inject constructor(
private val database: PedometerDatabase,
private val phoneStepsDataSource: PhoneStepsDataSource,
) {
suspend fun sync() {
private val stepsProvider: StepsProvider,
) : StepCacheSynchronizer {
override suspend fun sync() {
val today = LocalDate.now()
val start = today.minusDays(RECENT_HISTORY_DAYS - 1)
val phoneStepsByDay = phoneStepsDataSource.readDailySteps(start, today)
val phoneStepsByDay = stepsProvider.readDailySteps(start, today)

database.withTransaction {
val dailyStepsDao = database.dailyStepsDao()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,62 @@
package dev.sjaramillo.pedometer.data

import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.StepsRecord
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class HealthConnectSyncCoordinator @Inject constructor(
private val healthConnectClient: HealthConnectClient,
private val stepCacheSynchronizer: HealthConnectStepCacheSynchronizer,
private val stepsProvider: StepsProvider,
private val stepCacheSynchronizer: StepCacheSynchronizer,
) {
private val _state = MutableStateFlow<HealthConnectSyncState>(HealthConnectSyncState.Loading)
val state: StateFlow<HealthConnectSyncState> = _state.asStateFlow()

suspend fun refresh() {
if (READ_STEPS_PERMISSION !in healthConnectClient.permissionController.getGrantedPermissions()) {
_state.value = HealthConnectSyncState.PermissionRequired
return
private val refreshMutex = Mutex()
val isFake: Boolean get() = stepsProvider.isFake

suspend fun refresh() = refreshAfter {}

suspend fun refreshAfter(changeProvider: () -> Unit) {
refreshMutex.withLock {
changeProvider()
refreshLocked()
}
}

_state.value = HealthConnectSyncState.Syncing
runCatching { stepCacheSynchronizer.sync() }
.onSuccess { _state.value = HealthConnectSyncState.Ready }
.onFailure { _state.value = HealthConnectSyncState.Error }
private suspend fun refreshLocked() {
_state.value = HealthConnectSyncState.Loading
try {
when (stepsProvider.access()) {
StepsAccess.PermissionRequired -> _state.value = HealthConnectSyncState.PermissionRequired
StepsAccess.Unavailable -> _state.value = HealthConnectSyncState.Unavailable
StepsAccess.Available -> {
_state.value = HealthConnectSyncState.Syncing
stepCacheSynchronizer.sync()
_state.value = HealthConnectSyncState.Ready
}
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
_state.value = HealthConnectSyncState.Error
}
}

companion object {
val READ_STEPS_PERMISSION = HealthPermission.getReadPermission(StepsRecord::class)
val READ_STEPS_PERMISSION = dev.sjaramillo.pedometer.data.READ_STEPS_PERMISSION
}
}

enum class HealthConnectSyncState {
Loading,
PermissionRequired,
Unavailable,
Syncing,
Ready,
Error,
Expand Down
Loading
Loading