From d2ddd1a39ded1cc8f71542e2a0f80c39efbdbabf Mon Sep 17 00:00:00 2001 From: Severiano Jaramillo Date: Sun, 4 Oct 2026 14:57:32 -0700 Subject: [PATCH 1/8] build: give debug builds a separate application ID --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6d370e7..f3ded9f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -39,6 +39,7 @@ android { buildTypes { getByName("debug") { + applicationIdSuffix = ".debug" isMinifyEnabled = false } getByName("release") { From 6d9e4b5501ff670ede82eddd217d67ae9b0536e0 Mon Sep 17 00:00:00 2001 From: Severiano Jaramillo Date: Sun, 4 Oct 2026 15:00:06 -0700 Subject: [PATCH 2/8] refactor: abstract step providers and defer Health Connect initialization --- .../HealthConnectStepCacheSynchronizer.kt | 8 +- .../data/HealthConnectSyncCoordinator.kt | 34 +++--- .../pedometer/data/PhoneStepsDataSource.kt | 23 +++- .../pedometer/data/StepsProvider.kt | 22 ++++ .../pedometer/di/module/PersistenceModule.kt | 10 ++ .../sjaramillo/pedometer/ui/HomeFragment.kt | 5 + app/src/main/res/values/strings.xml | 1 + .../data/HealthConnectSyncCoordinatorTest.kt | 106 ++++++++++++++++++ 8 files changed, 187 insertions(+), 22 deletions(-) create mode 100644 app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsProvider.kt create mode 100644 app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectStepCacheSynchronizer.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectStepCacheSynchronizer.kt index 874773d..e6cdbbe 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectStepCacheSynchronizer.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectStepCacheSynchronizer.kt @@ -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() diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinator.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinator.kt index cd7e243..2f93770 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinator.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinator.kt @@ -1,8 +1,6 @@ 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 @@ -11,32 +9,40 @@ 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.Loading) val state: StateFlow = _state.asStateFlow() suspend fun refresh() { - if (READ_STEPS_PERMISSION !in healthConnectClient.permissionController.getGrantedPermissions()) { - _state.value = HealthConnectSyncState.PermissionRequired - return + _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 } - - _state.value = HealthConnectSyncState.Syncing - runCatching { stepCacheSynchronizer.sync() } - .onSuccess { _state.value = HealthConnectSyncState.Ready } - .onFailure { _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, diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/PhoneStepsDataSource.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/PhoneStepsDataSource.kt index e424682..f72af1c 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/PhoneStepsDataSource.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/PhoneStepsDataSource.kt @@ -6,6 +6,7 @@ import android.health.connect.HealthConnectException import android.health.connect.HealthConnectManager import android.os.OutcomeReceiver import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.records.StepsRecord import androidx.health.connect.client.records.metadata.DataOrigin import androidx.health.connect.client.request.AggregateGroupByPeriodRequest @@ -15,22 +16,34 @@ import java.time.LocalDate import java.time.LocalDateTime import java.time.Period import javax.inject.Inject +import javax.inject.Provider import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine /** Reads phone-recorded steps, including the current device's synthetic Health Connect origin. */ class PhoneStepsDataSource @Inject constructor( @ApplicationContext private val context: Context, - private val healthConnectClient: HealthConnectClient, -) { - suspend fun readDailySteps( + private val healthConnectClient: Provider, +) : StepsProvider { + override suspend fun access(): StepsAccess { + if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) { + return StepsAccess.Unavailable + } + return if (READ_STEPS_PERMISSION in healthConnectClient.get().permissionController.getGrantedPermissions()) { + StepsAccess.Available + } else { + StepsAccess.PermissionRequired + } + } + + override suspend fun readDailySteps( start: LocalDate, end: LocalDate, ): Map { val dataOrigins = phoneStepDataOrigins(getCurrentDeviceOrigin()) val response = - healthConnectClient.aggregateGroupByPeriod( + healthConnectClient.get().aggregateGroupByPeriod( AggregateGroupByPeriodRequest( metrics = setOf(StepsRecord.COUNT_TOTAL), timeRangeFilter = @@ -82,3 +95,5 @@ internal fun phoneStepDataOrigins(currentDeviceOrigin: String?): Set } private const val LEGACY_ANDROID_DATA_ORIGIN = "android" + +internal val READ_STEPS_PERMISSION = HealthPermission.getReadPermission(StepsRecord::class) diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsProvider.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsProvider.kt new file mode 100644 index 0000000..a75d7d9 --- /dev/null +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsProvider.kt @@ -0,0 +1,22 @@ +package dev.sjaramillo.pedometer.data + +import java.time.LocalDate + +interface StepsProvider { + suspend fun access(): StepsAccess + + suspend fun readDailySteps( + start: LocalDate, + end: LocalDate, + ): Map +} + +enum class StepsAccess { + Available, + PermissionRequired, + Unavailable, +} + +interface StepCacheSynchronizer { + suspend fun sync() +} diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/di/module/PersistenceModule.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/di/module/PersistenceModule.kt index c2accca..30fbdf9 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/di/module/PersistenceModule.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/di/module/PersistenceModule.kt @@ -8,7 +8,11 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import dev.sjaramillo.pedometer.data.HealthConnectStepCacheSynchronizer import dev.sjaramillo.pedometer.data.PedometerDatabase +import dev.sjaramillo.pedometer.data.PhoneStepsDataSource +import dev.sjaramillo.pedometer.data.StepCacheSynchronizer +import dev.sjaramillo.pedometer.data.StepsProvider import javax.inject.Singleton @Module @@ -26,6 +30,12 @@ class PersistenceModule { @ApplicationContext context: Context, ): HealthConnectClient = HealthConnectClient.getOrCreate(context) + @Provides + fun provideStepsProvider(phoneStepsDataSource: PhoneStepsDataSource): StepsProvider = phoneStepsDataSource + + @Provides + fun provideStepCacheSynchronizer(synchronizer: HealthConnectStepCacheSynchronizer): StepCacheSynchronizer = synchronizer + @Provides @Singleton fun provideStepsPreferences( diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt index e515e85..e7c9a3b 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt @@ -201,6 +201,11 @@ class HomeFragment : Fragment() { statusView.visibility = View.VISIBLE connectButton.visibility = View.VISIBLE } + HealthConnectSyncState.Unavailable -> { + statusView.setText(R.string.health_connect_unavailable) + statusView.visibility = View.VISIBLE + connectButton.visibility = View.GONE + } HealthConnectSyncState.Ready -> { statusView.visibility = View.GONE connectButton.visibility = View.GONE diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2012b31..96d18db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,4 +48,5 @@ Home Stats Settings + Health Connect is unavailable on this device. diff --git a/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt b/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt new file mode 100644 index 0000000..2e248f5 --- /dev/null +++ b/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt @@ -0,0 +1,106 @@ +package dev.sjaramillo.pedometer.data + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +class HealthConnectSyncCoordinatorTest { + @Test + fun `missing permission does not synchronize`() = + runBlocking { + val cache = RecordingCache() + val coordinator = HealthConnectSyncCoordinator(TestProvider(StepsAccess.PermissionRequired), cache) + + coordinator.refresh() + + assertEquals(HealthConnectSyncState.PermissionRequired, coordinator.state.value) + assertFalse(cache.synced) + } + + @Test + fun `unavailable provider does not synchronize`() = + runBlocking { + val cache = RecordingCache() + val coordinator = HealthConnectSyncCoordinator(TestProvider(StepsAccess.Unavailable), cache) + + coordinator.refresh() + + assertEquals(HealthConnectSyncState.Unavailable, coordinator.state.value) + assertFalse(cache.synced) + } + + @Test + fun `available provider synchronizes before becoming ready`() = + runBlocking { + val cache = RecordingCache() + val coordinator = HealthConnectSyncCoordinator(TestProvider(StepsAccess.Available), cache) + + coordinator.refresh() + + assertTrue(cache.synced) + assertEquals(HealthConnectSyncState.Ready, coordinator.state.value) + } + + @Test + fun `access failures become errors`() = + runBlocking { + val coordinator = HealthConnectSyncCoordinator(TestProvider(failure = IllegalStateException()), RecordingCache()) + + coordinator.refresh() + + assertEquals(HealthConnectSyncState.Error, coordinator.state.value) + } + + @Test + fun `sync failures become errors`() = + runBlocking { + val coordinator = HealthConnectSyncCoordinator(TestProvider(), RecordingCache(IllegalStateException())) + + coordinator.refresh() + + assertEquals(HealthConnectSyncState.Error, coordinator.state.value) + } + + @Test + fun `cancellation propagates`() = + runBlocking { + val coordinator = HealthConnectSyncCoordinator(TestProvider(), RecordingCache(CancellationException())) + var cancelled = false + + try { + coordinator.refresh() + } catch (error: CancellationException) { + cancelled = true + } + + assertTrue(cancelled) + } + + private class TestProvider( + private val access: StepsAccess = StepsAccess.Available, + private val failure: Exception? = null, + ) : StepsProvider { + override suspend fun access(): StepsAccess { + failure?.let { throw it } + return access + } + + override suspend fun readDailySteps( + start: LocalDate, + end: LocalDate, + ): Map = emptyMap() + } + + private class RecordingCache(private val failure: Exception? = null) : StepCacheSynchronizer { + var synced = false + + override suspend fun sync() { + failure?.let { throw it } + synced = true + } + } +} From 681f6a0bfaac4d953232198f8836eacb6f66f5ce Mon Sep 17 00:00:00 2001 From: Severiano Jaramillo Date: Sun, 4 Oct 2026 15:04:03 -0700 Subject: [PATCH 3/8] feat: add debug-only fake steps mode --- .../pedometer/data/DebugStepsProvider.kt | 33 ++++++++ .../pedometer/data/FakeStepsProvider.kt | 25 ++++++ .../di/module/StepsProviderModule.kt | 15 ++++ .../sjaramillo/pedometer/ui/DebugStepsMenu.kt | 36 ++++++++ app/src/debug/res/values/debug.xml | 6 ++ .../data/HealthConnectSyncCoordinator.kt | 16 +++- .../pedometer/data/StepsProvider.kt | 2 + .../pedometer/di/module/PersistenceModule.kt | 5 -- .../sjaramillo/pedometer/ui/HomeFragment.kt | 3 +- .../sjaramillo/pedometer/ui/MainActivity.kt | 10 ++- app/src/main/res/values/strings.xml | 1 + .../di/module/StepsProviderModule.kt | 15 ++++ .../sjaramillo/pedometer/ui/DebugStepsMenu.kt | 17 ++++ .../data/HealthConnectSyncCoordinatorTest.kt | 36 ++++++++ .../pedometer/data/DebugStepsProviderTest.kt | 83 +++++++++++++++++++ .../pedometer/data/FakeStepsProviderTest.kt | 33 ++++++++ 16 files changed, 327 insertions(+), 9 deletions(-) create mode 100644 app/src/debug/kotlin/dev/sjaramillo/pedometer/data/DebugStepsProvider.kt create mode 100644 app/src/debug/kotlin/dev/sjaramillo/pedometer/data/FakeStepsProvider.kt create mode 100644 app/src/debug/kotlin/dev/sjaramillo/pedometer/di/module/StepsProviderModule.kt create mode 100644 app/src/debug/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt create mode 100644 app/src/debug/res/values/debug.xml create mode 100644 app/src/release/kotlin/dev/sjaramillo/pedometer/di/module/StepsProviderModule.kt create mode 100644 app/src/release/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt create mode 100644 app/src/testDebug/kotlin/dev/sjaramillo/pedometer/data/DebugStepsProviderTest.kt create mode 100644 app/src/testDebug/kotlin/dev/sjaramillo/pedometer/data/FakeStepsProviderTest.kt diff --git a/app/src/debug/kotlin/dev/sjaramillo/pedometer/data/DebugStepsProvider.kt b/app/src/debug/kotlin/dev/sjaramillo/pedometer/data/DebugStepsProvider.kt new file mode 100644 index 0000000..5271665 --- /dev/null +++ b/app/src/debug/kotlin/dev/sjaramillo/pedometer/data/DebugStepsProvider.kt @@ -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, + 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 = selected().readDailySteps(start, end) + + private companion object { + const val FAKE_STEPS_KEY = "debug_fake_steps" + } +} diff --git a/app/src/debug/kotlin/dev/sjaramillo/pedometer/data/FakeStepsProvider.kt b/app/src/debug/kotlin/dev/sjaramillo/pedometer/data/FakeStepsProvider.kt new file mode 100644 index 0000000..c27215d --- /dev/null +++ b/app/src/debug/kotlin/dev/sjaramillo/pedometer/data/FakeStepsProvider.kt @@ -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 = + 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) + } +} diff --git a/app/src/debug/kotlin/dev/sjaramillo/pedometer/di/module/StepsProviderModule.kt b/app/src/debug/kotlin/dev/sjaramillo/pedometer/di/module/StepsProviderModule.kt new file mode 100644 index 0000000..4c1fcd5 --- /dev/null +++ b/app/src/debug/kotlin/dev/sjaramillo/pedometer/di/module/StepsProviderModule.kt @@ -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 +} diff --git a/app/src/debug/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt b/app/src/debug/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt new file mode 100644 index 0000000..85b3491 --- /dev/null +++ b/app/src/debug/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt @@ -0,0 +1,36 @@ +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 + val enabled = !provider.isFake + scope.launch { + coordinator.refreshAfter { provider.setFakeSteps(enabled) } + invalidate() + } + return true + } +} diff --git a/app/src/debug/res/values/debug.xml b/app/src/debug/res/values/debug.xml new file mode 100644 index 0000000..fa8b310 --- /dev/null +++ b/app/src/debug/res/values/debug.xml @@ -0,0 +1,6 @@ + + + Debug + Use fake steps + + diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinator.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinator.kt index 2f93770..7a1ab3c 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinator.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinator.kt @@ -4,6 +4,8 @@ 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 @@ -15,7 +17,19 @@ class HealthConnectSyncCoordinator @Inject constructor( private val _state = MutableStateFlow(HealthConnectSyncState.Loading) val state: StateFlow = _state.asStateFlow() - suspend fun refresh() { + private val refreshMutex = Mutex() + val isFake: Boolean get() = stepsProvider.isFake + + suspend fun refresh() = refreshAfter {} + + suspend fun refreshAfter(changeProvider: () -> Unit) { + refreshMutex.withLock { + changeProvider() + refreshLocked() + } + } + + private suspend fun refreshLocked() { _state.value = HealthConnectSyncState.Loading try { when (stepsProvider.access()) { diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsProvider.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsProvider.kt index a75d7d9..8916fc4 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsProvider.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsProvider.kt @@ -3,6 +3,8 @@ package dev.sjaramillo.pedometer.data import java.time.LocalDate interface StepsProvider { + val isFake: Boolean get() = false + suspend fun access(): StepsAccess suspend fun readDailySteps( diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/di/module/PersistenceModule.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/di/module/PersistenceModule.kt index 30fbdf9..856c474 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/di/module/PersistenceModule.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/di/module/PersistenceModule.kt @@ -10,9 +10,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dev.sjaramillo.pedometer.data.HealthConnectStepCacheSynchronizer import dev.sjaramillo.pedometer.data.PedometerDatabase -import dev.sjaramillo.pedometer.data.PhoneStepsDataSource import dev.sjaramillo.pedometer.data.StepCacheSynchronizer -import dev.sjaramillo.pedometer.data.StepsProvider import javax.inject.Singleton @Module @@ -30,9 +28,6 @@ class PersistenceModule { @ApplicationContext context: Context, ): HealthConnectClient = HealthConnectClient.getOrCreate(context) - @Provides - fun provideStepsProvider(phoneStepsDataSource: PhoneStepsDataSource): StepsProvider = phoneStepsDataSource - @Provides fun provideStepCacheSynchronizer(synchronizer: HealthConnectStepCacheSynchronizer): StepCacheSynchronizer = synchronizer diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt index e7c9a3b..35e5887 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt @@ -207,7 +207,8 @@ class HomeFragment : Fragment() { connectButton.visibility = View.GONE } HealthConnectSyncState.Ready -> { - statusView.visibility = View.GONE + statusView.setText(R.string.fake_steps_enabled) + statusView.visibility = if (healthConnectSyncCoordinator.isFake) View.VISIBLE else View.GONE connectButton.visibility = View.GONE } } diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/MainActivity.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/MainActivity.kt index fce7bc0..f308293 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/MainActivity.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/MainActivity.kt @@ -27,6 +27,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var healthConnectSyncCoordinator: HealthConnectSyncCoordinator + @Inject + lateinit var debugStepsMenu: DebugStepsMenu + private val requestHealthConnectPermission = registerForActivityResult(PermissionController.createRequestPermissionResultContract()) { refreshHealthConnect() @@ -87,17 +90,20 @@ class MainActivity : AppCompatActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_main, menu) + debugStepsMenu.addTo(menu) return true } - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (debugStepsMenu.select(item, lifecycleScope, ::invalidateOptionsMenu)) return true + return when (item.itemId) { R.id.action_about -> { showAboutDialog() true } else -> super.onOptionsItemSelected(item) } + } private fun showAboutDialog() { val text = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96d18db..4c99329 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,4 +49,5 @@ Stats Settings Health Connect is unavailable on this device. + Fake steps enabled diff --git a/app/src/release/kotlin/dev/sjaramillo/pedometer/di/module/StepsProviderModule.kt b/app/src/release/kotlin/dev/sjaramillo/pedometer/di/module/StepsProviderModule.kt new file mode 100644 index 0000000..1be47cc --- /dev/null +++ b/app/src/release/kotlin/dev/sjaramillo/pedometer/di/module/StepsProviderModule.kt @@ -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.PhoneStepsDataSource +import dev.sjaramillo.pedometer.data.StepsProvider + +@Module +@InstallIn(SingletonComponent::class) +abstract class StepsProviderModule { + @Binds + abstract fun bindStepsProvider(provider: PhoneStepsDataSource): StepsProvider +} diff --git a/app/src/release/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt b/app/src/release/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt new file mode 100644 index 0000000..dae2f29 --- /dev/null +++ b/app/src/release/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt @@ -0,0 +1,17 @@ +package dev.sjaramillo.pedometer.ui + +import android.view.Menu +import android.view.MenuItem +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +@Suppress("UNUSED_PARAMETER") +class DebugStepsMenu @Inject constructor() { + fun addTo(menu: Menu) = Unit + + fun select( + item: MenuItem, + scope: CoroutineScope, + invalidate: () -> Unit, + ): Boolean = false +} diff --git a/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt b/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt index 2e248f5..1957e3d 100644 --- a/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt +++ b/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt @@ -1,6 +1,9 @@ package dev.sjaramillo.pedometer.data import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -80,6 +83,39 @@ class HealthConnectSyncCoordinatorTest { assertTrue(cancelled) } + @Test + fun `provider change waits for prior cache commit`() = + runBlocking { + val started = CompletableDeferred() + val finish = CompletableDeferred() + var fake = false + val commits = mutableListOf() + val cache = + object : StepCacheSynchronizer { + override suspend fun sync() { + if (!fake) { + started.complete(Unit) + finish.await() + } + commits.add(fake) + } + } + val coordinator = HealthConnectSyncCoordinator(TestProvider(), cache) + val refresh = launch { coordinator.refresh() } + started.await() + val switch = + launch(start = CoroutineStart.UNDISPATCHED) { + coordinator.refreshAfter { fake = true } + } + assertFalse(fake) + finish.complete(Unit) + refresh.join() + switch.join() + + assertEquals(listOf(false, true), commits) + assertEquals(HealthConnectSyncState.Ready, coordinator.state.value) + } + private class TestProvider( private val access: StepsAccess = StepsAccess.Available, private val failure: Exception? = null, diff --git a/app/src/testDebug/kotlin/dev/sjaramillo/pedometer/data/DebugStepsProviderTest.kt b/app/src/testDebug/kotlin/dev/sjaramillo/pedometer/data/DebugStepsProviderTest.kt new file mode 100644 index 0000000..38d42b1 --- /dev/null +++ b/app/src/testDebug/kotlin/dev/sjaramillo/pedometer/data/DebugStepsProviderTest.kt @@ -0,0 +1,83 @@ +package dev.sjaramillo.pedometer.data + +import android.content.SharedPreferences +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Proxy +import java.time.LocalDate +import javax.inject.Provider + +class DebugStepsProviderTest { + @Test + fun `real is default and selection survives provider recreation`() { + val preferences = preferences() + val provider = provider(preferences) + assertFalse(provider.isFake) + + provider.setFakeSteps(true) + assertTrue(provider(preferences).isFake) + + provider.setFakeSteps(false) + assertFalse(provider(preferences).isFake) + } + + @Test + fun `fake mode never constructs Health Connect reader`() = + runBlocking { + val provider = provider(preferences()) + provider.setFakeSteps(true) + val day = LocalDate.ofEpochDay(0) + + assertEquals(StepsAccess.Available, provider.access()) + assertEquals(mapOf(day to 1500L), provider.readDailySteps(day, day)) + } + + @Test + fun `switching back selects real reader`() = + runBlocking { + val provider = provider(preferences()) + provider.setFakeSteps(true) + provider.setFakeSteps(false) + var selectedReal = false + try { + provider.access() + } catch (error: IllegalStateException) { + selectedReal = error.message == "Real reader selected" + } + assertTrue(selectedReal) + } + + private fun provider(preferences: SharedPreferences): DebugStepsProvider = + DebugStepsProvider(preferences, Provider { error("Real reader selected") }, FakeStepsProvider()) + + private fun preferences(): SharedPreferences { + val values = mutableMapOf() + val editor = + Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { proxy, method, args -> + when (method.name) { + "putBoolean" -> { + values[args[0] as String] = args[1] as Boolean + proxy + } + "apply" -> null + else -> error("Unexpected editor call: ${method.name}") + } + } as SharedPreferences.Editor + return Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, args -> + when (method.name) { + "getBoolean" -> values[args[0] as String] ?: args[1] as Boolean + "edit" -> editor + else -> error("Unexpected preferences call: ${method.name}") + } + } as SharedPreferences + } +} diff --git a/app/src/testDebug/kotlin/dev/sjaramillo/pedometer/data/FakeStepsProviderTest.kt b/app/src/testDebug/kotlin/dev/sjaramillo/pedometer/data/FakeStepsProviderTest.kt new file mode 100644 index 0000000..82ec697 --- /dev/null +++ b/app/src/testDebug/kotlin/dev/sjaramillo/pedometer/data/FakeStepsProviderTest.kt @@ -0,0 +1,33 @@ +package dev.sjaramillo.pedometer.data + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate + +class FakeStepsProviderTest { + @Test + fun `generates inclusive deterministic daily history`() = + runBlocking { + val provider = FakeStepsProvider() + val start = LocalDate.ofEpochDay(0) + val result = provider.readDailySteps(start, start.plusDays(29)) + + assertEquals(30, result.size) + assertEquals(1500L, result[start]) + assertEquals(14000L, result[start.plusDays(29)]) + assertEquals(result, provider.readDailySteps(start, start.plusDays(29))) + assertEquals(result[start.plusDays(10)], provider.readDailySteps(start.plusDays(10), start.plusDays(10))[start.plusDays(10)]) + } + + @Test + fun `empty reversed ranges and pre epoch dates are supported`() = + runBlocking { + val provider = FakeStepsProvider() + val start = LocalDate.ofEpochDay(-1) + + assertEquals(emptyMap(), provider.readDailySteps(start, start.minusDays(1))) + assertEquals(mapOf(start to 14000L), provider.readDailySteps(start, start)) + assertEquals(StepsAccess.Available, provider.access()) + } +} From 33264f18bc9f30e6df1b2d689e21610b33a4f724 Mon Sep 17 00:00:00 2001 From: Severiano Jaramillo Date: Sun, 4 Oct 2026 15:09:11 -0700 Subject: [PATCH 4/8] fix: refresh step screens after provider synchronization --- README.md | 39 ++++++++- app/build.gradle.kts | 1 + .../pedometer/data/DailyStepsDao.kt | 4 + .../pedometer/data/StepsRepository.kt | 4 + .../sjaramillo/pedometer/ui/HomeFragment.kt | 26 ++++-- .../sjaramillo/pedometer/ui/MainActivity.kt | 1 + .../pedometer/ui/stats/StatsViewModel.kt | 18 +++-- app/src/main/res/values/strings.xml | 1 + .../pedometer/ui/stats/StatsViewModelTest.kt | 79 +++++++++++++++++++ gradle/libs.versions.toml | 2 + 10 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 app/src/test/kotlin/dev/sjaramillo/pedometer/ui/stats/StatsViewModelTest.kt diff --git a/README.md b/README.md index 6086d14..8151959 100644 --- a/README.md +++ b/README.md @@ -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** and the +same icon for now. 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f3ded9f..5238030 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -103,6 +103,7 @@ dependencies { // Unit Tests testImplementation(libs.junit) + testImplementation(libs.coroutines.test) } ksp { diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/DailyStepsDao.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/DailyStepsDao.kt index 4e3ecdb..38ebaca 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/DailyStepsDao.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/DailyStepsDao.kt @@ -4,12 +4,16 @@ 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 + @Query("SELECT * FROM daily_steps WHERE day>0 ORDER BY day") + fun observeAll(): Flow> + @Query("SELECT steps FROM daily_steps WHERE day=:day") suspend fun getSteps(day: Long): Long? diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsRepository.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsRepository.kt index faa1556..76c850f 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsRepository.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsRepository.kt @@ -1,6 +1,8 @@ package dev.sjaramillo.pedometer.data import androidx.room.withTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import java.time.LocalDate import javax.inject.Inject @@ -9,6 +11,8 @@ class StepsRepository @Inject constructor( ) { private val dailyStepsDao = database.dailyStepsDao() + fun observeAll(): Flow> = dailyStepsDao.observeAll().distinctUntilChanged() + suspend fun getAll(): List = dailyStepsDao.getAll() suspend fun getStepsToday(): Long = dailyStepsDao.getSteps(LocalDate.now().toEpochDay()) ?: 0 diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt index 35e5887..a46728e 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt @@ -26,6 +26,7 @@ import org.eazegraph.lib.charts.BarChart import org.eazegraph.lib.charts.PieChart import org.eazegraph.lib.models.BarModel import org.eazegraph.lib.models.PieModel +import java.time.LocalDate import java.time.format.DateTimeFormatter import javax.inject.Inject import kotlin.math.roundToLong @@ -80,13 +81,14 @@ class HomeFragment : Fragment() { savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - loadStepData() viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - healthConnectSyncCoordinator.state.collect { state -> - updateHealthConnectState(state) - if (state == HealthConnectSyncState.Ready) { - loadStepData() + launch { + healthConnectSyncCoordinator.state.collect(::updateHealthConnectState) + } + launch { + stepsRepository.observeAll().collect { entries -> + renderStepData(entries) } } } @@ -97,7 +99,7 @@ class HomeFragment : Fragment() { super.onResume() val prefs = requireContext().getSharedPreferences("pedometer", Context.MODE_PRIVATE) goal = prefs.getInt("goal", SettingsFragment.DEFAULT_GOAL) - renderUnit() + loadStepData() } private fun renderUnit() { @@ -153,6 +155,12 @@ class HomeFragment : Fragment() { } } + private fun renderStepData(entries: List) { + renderUnit() + updatePie(entries.firstOrNull { it.day == LocalDate.now().toEpochDay() }?.steps ?: 0) + updateBars(entries.takeLast(8).asReversed()) + } + private fun updateBars(lastEntries: List) { val barChart = requireView().findViewById(R.id.bargraph) if (barChart.data.isNotEmpty()) barChart.clearChart() @@ -197,9 +205,11 @@ class HomeFragment : Fragment() { connectButton.visibility = View.GONE } HealthConnectSyncState.Error -> { - statusView.setText(R.string.health_connect_error) + statusView.setText( + if (healthConnectSyncCoordinator.isFake) R.string.fake_steps_error else R.string.health_connect_error, + ) statusView.visibility = View.VISIBLE - connectButton.visibility = View.VISIBLE + connectButton.visibility = if (healthConnectSyncCoordinator.isFake) View.GONE else View.VISIBLE } HealthConnectSyncState.Unavailable -> { statusView.setText(R.string.health_connect_unavailable) diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/MainActivity.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/MainActivity.kt index f308293..5608bc8 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/MainActivity.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/MainActivity.kt @@ -59,6 +59,7 @@ class MainActivity : AppCompatActivity() { } fun requestHealthConnectAccess() { + if (healthConnectSyncCoordinator.isFake) return requestHealthConnectPermission.launch(setOf(HealthConnectSyncCoordinator.READ_STEPS_PERMISSION)) } diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/stats/StatsViewModel.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/stats/StatsViewModel.kt index cc59bd8..2b70ac4 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/stats/StatsViewModel.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/stats/StatsViewModel.kt @@ -7,13 +7,17 @@ import dev.sjaramillo.pedometer.data.DailySteps import dev.sjaramillo.pedometer.data.StepsRepository import dev.sjaramillo.pedometer.util.DateUtil import dev.sjaramillo.pedometer.util.FormatUtil +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import java.time.Clock import java.time.LocalDate class StatsViewModel( - private val stepsRepository: StepsRepository, + private val dailySteps: Flow>, + private val clock: Clock = Clock.systemDefaultZone(), ) : ViewModel() { private val numberFormat = FormatUtil.numberFormat private val dateFormat = FormatUtil.dateFormat @@ -23,16 +27,14 @@ class StatsViewModel( init { viewModelScope.launch { - loadStats() + dailySteps.collect { entries -> + _uiState.value = StatsUiState.Success(createStatsData(entries)) + } } } - private suspend fun loadStats() { - _uiState.value = StatsUiState.Success(createStatsData(stepsRepository.getAll())) - } - private fun createStatsData(dailySteps: List): StatsData { - val today = LocalDate.now() + val today = LocalDate.now(clock) val stepsByDay = dailySteps.associate { DateUtil.dayToLocalDate(it.day) to it.steps } fun totalSince(start: LocalDate): Long = @@ -73,6 +75,6 @@ class StatsViewModelFactory( @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { require(modelClass.isAssignableFrom(StatsViewModel::class.java)) - return StatsViewModel(stepsRepository) as T + return StatsViewModel(stepsRepository.observeAll()) as T } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c99329..e42990f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,4 +50,5 @@ Settings Health Connect is unavailable on this device. Fake steps enabled + Unable to refresh fake steps. Try again from the Debug menu. diff --git a/app/src/test/kotlin/dev/sjaramillo/pedometer/ui/stats/StatsViewModelTest.kt b/app/src/test/kotlin/dev/sjaramillo/pedometer/ui/stats/StatsViewModelTest.kt new file mode 100644 index 0000000..7990e3c --- /dev/null +++ b/app/src/test/kotlin/dev/sjaramillo/pedometer/ui/stats/StatsViewModelTest.kt @@ -0,0 +1,79 @@ +package dev.sjaramillo.pedometer.ui.stats + +import androidx.lifecycle.viewModelScope +import dev.sjaramillo.pedometer.data.DailySteps +import dev.sjaramillo.pedometer.util.FormatUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Clock +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +@OptIn(ExperimentalCoroutinesApi::class) +class StatsViewModelTest { + private val today = LocalDate.of(2026, 10, 4) + private val clock = Clock.fixed(Instant.parse("2026-10-04T12:00:00Z"), ZoneOffset.UTC) + + @Test + fun `stats refresh when synchronized or imported rows change`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val entries = MutableStateFlow(listOf(DailySteps(today.toEpochDay(), 1500))) + val model = StatsViewModel(entries, clock) + try { + advanceUntilIdle() + assertEquals(formatted(1500), stats(model).totalStepsAllTime) + + entries.value = listOf(DailySteps(today.minusDays(1).toEpochDay(), 4500), DailySteps(today.toEpochDay(), 14000)) + advanceUntilIdle() + + assertEquals(formatted(18500), stats(model).totalStepsLast7Days) + assertEquals(formatted(14000), stats(model).recordSteps) + assertEquals(formatted(18500), stats(model).totalStepsThisMonth) + + entries.value = listOf(DailySteps(today.minusYears(1).toEpochDay(), 20000), DailySteps(today.toEpochDay(), 7500)) + advanceUntilIdle() + + assertEquals(formatted(27500), stats(model).totalStepsAllTime) + assertEquals(formatted(7500), stats(model).totalStepsThisYear) + } finally { + model.viewModelScope.cancel() + Dispatchers.resetMain() + } + } + + @Test + fun `empty history updates after initial sync`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val entries = MutableStateFlow(emptyList()) + val model = StatsViewModel(entries, clock) + try { + advanceUntilIdle() + assertEquals(formatted(0), stats(model).totalStepsAllTime) + assertEquals("—", stats(model).recordDate) + + entries.value = listOf(DailySteps(today.toEpochDay(), 10500)) + advanceUntilIdle() + + assertEquals(formatted(10500), stats(model).totalStepsAllTime) + } finally { + model.viewModelScope.cancel() + Dispatchers.resetMain() + } + } + + private fun stats(model: StatsViewModel): StatsData = (model.uiState.value as StatsUiState.Success).statsData + + private fun formatted(value: Long): String = FormatUtil.numberFormat.format(value) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0ca6ec9..c65aeec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +coroutines = "1.10.2" activity = "1.4.0" android-gradle = "9.3.0" appcompat = "1.4.2" @@ -20,6 +21,7 @@ preference = "1.2.0" room = "2.7.2" [libraries] +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } activity-core = { module = "androidx.activity:activity-ktx", version.ref = "activity" } android-gradle = { module = "com.android.tools.build:gradle", version.ref = "android-gradle" } From 5106139133209d5da35f4cfc8d6f5cdd43db8392 Mon Sep 17 00:00:00 2001 From: Severiano Jaramillo Date: Sun, 4 Oct 2026 18:12:47 -0700 Subject: [PATCH 5/8] feat: distinguish the debug launcher icon with a D --- README.md | 4 +- .../res/drawable/ic_launcher_foreground.xml | 57 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 app/src/debug/res/drawable/ic_launcher_foreground.xml diff --git a/README.md b/README.md index 8151959..6c163f0 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ 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** and the -same icon for now. The existing debug signing configuration is unchanged. +(`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 diff --git a/app/src/debug/res/drawable/ic_launcher_foreground.xml b/app/src/debug/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..9e956fd --- /dev/null +++ b/app/src/debug/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + From 4e3e2559afd7e2735f7ee52dea1a55846d186c77 Mon Sep 17 00:00:00 2001 From: Severiano Jaramillo Date: Sun, 4 Oct 2026 18:15:42 -0700 Subject: [PATCH 6/8] fix: place the debug D behind the foot heel --- app/src/debug/res/drawable/ic_launcher_foreground.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/debug/res/drawable/ic_launcher_foreground.xml b/app/src/debug/res/drawable/ic_launcher_foreground.xml index 9e956fd..f871d9a 100644 --- a/app/src/debug/res/drawable/ic_launcher_foreground.xml +++ b/app/src/debug/res/drawable/ic_launcher_foreground.xml @@ -10,10 +10,10 @@ android:pivotY="24" android:scaleX="0.68" android:scaleY="0.68"> - + Date: Sun, 4 Oct 2026 18:20:40 -0700 Subject: [PATCH 7/8] fix: shift the debug icon D two units left --- app/src/debug/res/drawable/ic_launcher_foreground.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/debug/res/drawable/ic_launcher_foreground.xml b/app/src/debug/res/drawable/ic_launcher_foreground.xml index f871d9a..e5354b7 100644 --- a/app/src/debug/res/drawable/ic_launcher_foreground.xml +++ b/app/src/debug/res/drawable/ic_launcher_foreground.xml @@ -13,7 +13,7 @@ Date: Sun, 4 Oct 2026 18:31:23 -0700 Subject: [PATCH 8/8] refactor: consolidate Home state and serialize debug toggles --- .../sjaramillo/pedometer/ui/DebugStepsMenu.kt | 3 +- .../pedometer/data/DailyStepsDao.kt | 6 --- .../pedometer/data/StepsRepository.kt | 5 --- .../sjaramillo/pedometer/ui/HomeFragment.kt | 19 ++++---- .../sjaramillo/pedometer/ui/HomeViewModel.kt | 26 +++++++++++ .../data/HealthConnectSyncCoordinatorTest.kt | 35 +++++++++++++++ .../pedometer/ui/HomeViewModelTest.kt | 45 +++++++++++++++++++ 7 files changed, 115 insertions(+), 24 deletions(-) create mode 100644 app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeViewModel.kt create mode 100644 app/src/test/kotlin/dev/sjaramillo/pedometer/ui/HomeViewModelTest.kt diff --git a/app/src/debug/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt b/app/src/debug/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt index 85b3491..bb9bd46 100644 --- a/app/src/debug/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt +++ b/app/src/debug/kotlin/dev/sjaramillo/pedometer/ui/DebugStepsMenu.kt @@ -26,9 +26,8 @@ class DebugStepsMenu @Inject constructor( invalidate: () -> Unit, ): Boolean { if (item.itemId != R.id.action_fake_steps) return false - val enabled = !provider.isFake scope.launch { - coordinator.refreshAfter { provider.setFakeSteps(enabled) } + coordinator.refreshAfter { provider.setFakeSteps(!provider.isFake) } invalidate() } return true diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/DailyStepsDao.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/DailyStepsDao.kt index 38ebaca..219f790 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/DailyStepsDao.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/DailyStepsDao.kt @@ -14,12 +14,6 @@ interface DailyStepsDao { @Query("SELECT * FROM daily_steps WHERE day>0 ORDER BY day") fun observeAll(): Flow> - @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 - @Query("SELECT * FROM daily_steps WHERE day >= :start AND day <= :end") suspend fun getEntriesInRange( start: Long, diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsRepository.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsRepository.kt index 76c850f..4582747 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsRepository.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/data/StepsRepository.kt @@ -3,7 +3,6 @@ package dev.sjaramillo.pedometer.data import androidx.room.withTransaction import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged -import java.time.LocalDate import javax.inject.Inject class StepsRepository @Inject constructor( @@ -15,10 +14,6 @@ class StepsRepository @Inject constructor( suspend fun getAll(): List = dailyStepsDao.getAll() - suspend fun getStepsToday(): Long = dailyStepsDao.getSteps(LocalDate.now().toEpochDay()) ?: 0 - - suspend fun getLastEntries(num: Int): List = dailyStepsDao.getLastEntries(num) - suspend fun importDailySteps(dailySteps: List) { database.withTransaction { dailyStepsDao.insertAll(dailySteps) diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt index a46728e..b4d1cb9 100644 --- a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeFragment.kt @@ -9,6 +9,7 @@ import android.view.ViewGroup import android.widget.Button import android.widget.TextView import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -39,6 +40,10 @@ class HomeFragment : Fragment() { @Inject lateinit var healthConnectSyncCoordinator: HealthConnectSyncCoordinator + private val homeViewModel: HomeViewModel by viewModels { + HomeViewModelFactory(stepsRepository) + } + private lateinit var stepsView: TextView private lateinit var sliceGoal: PieModel private lateinit var sliceCurrent: PieModel @@ -69,7 +74,7 @@ class HomeFragment : Fragment() { graph.addPieSlice(sliceGoal) graph.setOnClickListener { showSteps = !showSteps - renderUnit() + renderStepData(homeViewModel.entries.value) } graph.isDrawValueInPie = false graph.isUsePieRotation = true @@ -87,7 +92,7 @@ class HomeFragment : Fragment() { healthConnectSyncCoordinator.state.collect(::updateHealthConnectState) } launch { - stepsRepository.observeAll().collect { entries -> + homeViewModel.entries.collect { entries -> renderStepData(entries) } } @@ -99,7 +104,7 @@ class HomeFragment : Fragment() { super.onResume() val prefs = requireContext().getSharedPreferences("pedometer", Context.MODE_PRIVATE) goal = prefs.getInt("goal", SettingsFragment.DEFAULT_GOAL) - loadStepData() + renderStepData(homeViewModel.entries.value) } private fun renderUnit() { @@ -147,14 +152,6 @@ class HomeFragment : Fragment() { } } - private fun loadStepData() { - viewLifecycleOwner.lifecycleScope.launch { - renderUnit() - updatePie(stepsRepository.getStepsToday()) - updateBars(stepsRepository.getLastEntries(8)) - } - } - private fun renderStepData(entries: List) { renderUnit() updatePie(entries.firstOrNull { it.day == LocalDate.now().toEpochDay() }?.steps ?: 0) diff --git a/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeViewModel.kt b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeViewModel.kt new file mode 100644 index 0000000..a8d5e23 --- /dev/null +++ b/app/src/main/kotlin/dev/sjaramillo/pedometer/ui/HomeViewModel.kt @@ -0,0 +1,26 @@ +package dev.sjaramillo.pedometer.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dev.sjaramillo.pedometer.data.DailySteps +import dev.sjaramillo.pedometer.data.StepsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +class HomeViewModel(dailySteps: Flow>) : ViewModel() { + val entries: StateFlow> = + dailySteps.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) +} + +class HomeViewModelFactory( + private val stepsRepository: StepsRepository, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(HomeViewModel::class.java)) + return HomeViewModel(stepsRepository.observeAll()) as T + } +} diff --git a/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt b/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt index 1957e3d..56c441a 100644 --- a/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt +++ b/app/src/test/kotlin/dev/sjaramillo/pedometer/data/HealthConnectSyncCoordinatorTest.kt @@ -116,6 +116,41 @@ class HealthConnectSyncCoordinatorTest { assertEquals(HealthConnectSyncState.Ready, coordinator.state.value) } + @Test + fun `queued toggles each use the mode left by the previous refresh`() = + runBlocking { + val started = CompletableDeferred() + val finish = CompletableDeferred() + var fake = false + val commits = mutableListOf() + val cache = + object : StepCacheSynchronizer { + override suspend fun sync() { + if (commits.isEmpty()) { + started.complete(Unit) + finish.await() + } + commits.add(fake) + } + } + val coordinator = HealthConnectSyncCoordinator(TestProvider(), cache) + val refresh = launch { coordinator.refresh() } + started.await() + val toggles = + List(2) { + launch(start = CoroutineStart.UNDISPATCHED) { + coordinator.refreshAfter { fake = !fake } + } + } + assertFalse(fake) + finish.complete(Unit) + refresh.join() + toggles.forEach { it.join() } + + assertEquals(listOf(false, true, false), commits) + assertFalse(fake) + } + private class TestProvider( private val access: StepsAccess = StepsAccess.Available, private val failure: Exception? = null, diff --git a/app/src/test/kotlin/dev/sjaramillo/pedometer/ui/HomeViewModelTest.kt b/app/src/test/kotlin/dev/sjaramillo/pedometer/ui/HomeViewModelTest.kt new file mode 100644 index 0000000..b64c2bf --- /dev/null +++ b/app/src/test/kotlin/dev/sjaramillo/pedometer/ui/HomeViewModelTest.kt @@ -0,0 +1,45 @@ +package dev.sjaramillo.pedometer.ui + +import androidx.lifecycle.viewModelScope +import dev.sjaramillo.pedometer.data.DailySteps +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelTest { + @Test + fun `home retains updated history while the view has no collector`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val rows = MutableSharedFlow>() + val model = HomeViewModel(rows) + try { + advanceUntilIdle() + val initial = listOf(DailySteps(20000, 1500)) + rows.emit(initial) + advanceUntilIdle() + assertEquals(initial, model.entries.first()) + + val updated = listOf(DailySteps(20000, 4500), DailySteps(20001, 14000)) + rows.emit(updated) + advanceUntilIdle() + + assertEquals(updated, model.entries.value) + assertEquals(updated, model.entries.first()) + assertEquals(1, rows.subscriptionCount.value) + } finally { + model.viewModelScope.cancel() + Dispatchers.resetMain() + } + } +}