Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3f5e0b8
Add phone-based step source (Health Connect)
Oct 5, 2026
9e03378
Add regression tests for the phone step source
Oct 5, 2026
a9ee095
Add phoneSteps column to ActivityDailyEntity + Room migration
Oct 6, 2026
361eb61
Phone step write: write only phoneSteps, leave ring columns untouched
Oct 6, 2026
22ba468
Read path: pick phoneSteps vs steps at display time by user preference
Oct 6, 2026
03f7c51
Read path: widgets and notifications honor the step-source preference
Oct 6, 2026
21f81c0
Delete the in-memory phone-ownership tracking
Oct 6, 2026
98b606a
Exporter: drop the phone exclusion; plausibility guards already skip …
Oct 6, 2026
3b50a78
Fix Step Source switch: permission filter + rapid-toggle race
Oct 7, 2026
ce32b21
PulseEventBus: hold events until first subscriber attaches
Oct 7, 2026
7bf8921
PhoneStepManager: filter foreign origins instead of subtracting our own
Oct 7, 2026
f13752a
PhoneStepManager: publish 0 for today so phone mode reads 0, not stal…
Oct 7, 2026
7a18764
Activity: calorie goal ring uses active, not total, calories
Oct 7, 2026
d7fa93d
PhoneStepManager: share the HC client + steps-permission preamble
Oct 7, 2026
afc91a5
Step Source: throttle the automatic backfill after an explicit one
Oct 7, 2026
ba613fb
Add trailing newlines to files that lost them
Oct 7, 2026
1134108
Fix PhoneStepSourceTest: EXCLUDED_SOURCES no longer contains phone
Oct 7, 2026
22cdd50
Test read-time step-source selection (displaySteps / forDisplay)
Oct 7, 2026
e19c732
Test the phone write's column-only contract
Oct 7, 2026
6ccd278
EventPersistenceSubscriber: fix stale EXCLUDED_SOURCES KDocs
Oct 7, 2026
f701393
Backup/restore: carry phoneSteps through the archive
Oct 25, 2026
b879868
Persist ring events from Application.onCreate, not the composition
Oct 25, 2026
afc91d0
Phone steps: publish 0 only after a source has proven it writes steps
Oct 25, 2026
98bd96e
Phone fallback: clear today's phoneSteps when the reader fails
Oct 25, 2026
dd45f24
Phone write: preserve updatedAt on existing rows
Oct 25, 2026
e21d481
Coach: read display steps through DisplaySteps
Oct 25, 2026
c38f2b6
Coach tools: thread step-source preference into ToolExecutionContext
Oct 25, 2026
46bca80
Coach tools: read display steps through DisplaySteps
Oct 25, 2026
3d9be01
Display steps: route every reader through DisplaySteps
Oct 26, 2026
86b8875
Step source: refresh screens when the toggle changes
Oct 26, 2026
4abc45c
Phone steps: paginate foreignStepOrigins
Oct 26, 2026
019df68
Activity: show Records on phone-mode days the phone never backfilled
Oct 26, 2026
6efa020
Review cleanup: stale KDoc, mis-indented comment, wrong test filename
Oct 26, 2026
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
8 changes: 7 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@
<!-- Phase 8: Health Connect export (write-only). Exactly the Phase 1–4 data types from
docs/health-connect-integration.md — Phase 5 types (blood pressure, glucose, respiratory
rate, VO2max, resting HR, nutrition) - the Phase 5 "beyond iOS" types, added in Phase 5 so the
first permission sheet stayed narrow. No READ_* permissions anywhere. -->
first permission sheet stayed narrow. READ_STEPS below is the one exception: it supports
the optional phone step source, and users on the default "Ring" source never see the
Health Connect read prompt and never trigger a read. -->
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
<uses-permission android:name="android.permission.health.WRITE_OXYGEN_SATURATION" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE_VARIABILITY" />
Expand All @@ -62,6 +64,10 @@
<uses-permission android:name="android.permission.health.WRITE_VO2_MAX" />
<uses-permission android:name="android.permission.health.WRITE_RESTING_HEART_RATE" />
<uses-permission android:name="android.permission.health.WRITE_NUTRITION" />
<!-- Phone step source (fork feature): the only Health Connect READ permission the app
requests, and only when the user opts into "Phone" as their step source. Default
("Ring") users never trigger a read and never see the prompt. -->
<uses-permission android:name="android.permission.health.READ_STEPS" />

<application
android:name=".PulseLoopApplication"
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/pulseloop/AppServices.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class AppServices private constructor(context: Context) {
val db: PulseLoopDatabase = PulseLoopDatabase.getInstance(context)
val bleClient = RingBLEClient(context)
val apiKeyStore = ApiKeyStore(context)
val stepSourcePrefs = com.pulseloop.settings.StepSourcePrefs(context)
val coordinator = RingSyncCoordinator(bleClient, db, apiKeyStore)
val gpsRecorder = GpsRouteRecorder(context, db)
val liveWorkout = LiveWorkoutManager(coordinator, db, gpsRecorder, context)
Expand All @@ -47,7 +48,7 @@ class AppServices private constructor(context: Context) {
)
val batteryAlerts = BatteryAlertMonitor(context)
val providerStore = CoachProviderSettingsStore(context)
val summaryCoordinator = CoachSummaryCoordinator(db, apiKeyStore, providerStore)
val summaryCoordinator = CoachSummaryCoordinator(db, apiKeyStore, providerStore, stepSourcePrefs)

// iOS #94: runs the due check-in slot the moment a full sync completes, so a slot the periodic
// worker skipped for stale data is delivered a few minutes later instead of being lost.
Expand Down
57 changes: 57 additions & 0 deletions app/src/main/java/com/pulseloop/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.pulseloop.health.HealthConnectPermissionReconcile
import com.pulseloop.notifications.CoachNotifications
import com.pulseloop.settings.StepSourcePrefs
import com.pulseloop.strava.StravaAuth
import com.pulseloop.strava.StravaTokenStore
import com.pulseloop.ui.PulseLoopApp
Expand All @@ -25,6 +26,13 @@ import kotlinx.coroutines.launch
* - Android 12+: BLUETOOTH_SCAN + BLUETOOTH_CONNECT
* - Android < 12: ACCESS_FINE_LOCATION (for BLE scanning)
* - Android 13+: POST_NOTIFICATIONS
*
* Health Connect READ_STEPS is deliberately **not** requested here — it is
* requested only from the Step Source row in Settings, on the user's explicit
* action. This means users on the default "Ring" step source never see the
* prompt, and users who declined are not asked again on every foreground.
* ACTIVITY_RECOGNITION is not requested at all: the fork reads steps from
* Health Connect, never from the raw hardware step counter.
*/
class MainActivity : ComponentActivity() {

Expand Down Expand Up @@ -68,6 +76,7 @@ class MainActivity : ComponentActivity() {
super.onResume()
// Re-request on resume in case user granted in Settings
requestAllPermissions()
refreshPhoneSteps()
if (hasAllBlePermissions() && hasNotificationPermission()) {
CoachNotifications.schedule(this)
}
Expand Down Expand Up @@ -193,4 +202,52 @@ class MainActivity : ComponentActivity() {
private fun hasFineLocation() = ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED

/**
* Refresh today's phone step count and backfill the last 30 days from
* Health Connect, when the user's step source is set to "Phone".
*
* If the Health Connect read permission is missing, this quietly returns
* without prompting. The permission is requested from Settings → Step
* Source, on the user's explicit action — never from here, so neither app
* launch nor return-to-foreground re-triggers a dialog the user may have
* already declined.
*
* On failure, publishes [PulseEvent.PhoneStepSourceUnavailable]. The
* persistence layer responds by clearing *today's* `phoneSteps` on the
* `activity_daily` row, so the display's read-time pick falls back to
* the ring's live count instead of leaving a stale phone value on screen
* for the rest of the day. Historical days keep their `phoneSteps`.
*/
private fun refreshPhoneSteps() {
val prefs = StepSourcePrefs(this)
if (prefs.stepSource != StepSourcePrefs.SOURCE_PHONE) return

lifecycleScope.launch(Dispatchers.IO) {
val manager = PhoneStepManager(this@MainActivity)
if (!manager.refresh()) {
// The phone reader couldn't produce a value — permission revoked, the
// provider uninstalled, or a device with no Health Connect. Signal the
// persistence layer so it clears today's phoneSteps and the display falls
// back to the ring's live count instead of a stale phone value.
com.pulseloop.ring.PulseEventBus.publishBlocking(
com.pulseloop.ring.PulseEvent.PhoneStepSourceUnavailable
)
return@launch
}
// A10: the 30-day historical backfill runs at most once per local day. It has to
// run today's refresh on every foreground — the phone has been walking since the
// last one — but re-reading 30 days of Health Connect on every resume, writing
// every row's updatedAt forward, and having the exporter re-select and re-export
// the whole tail is wasted work. The toggle in SettingsScreen still calls
// refreshHistoricalDays() directly and is not throttled: an explicit user action
// should always run.
val today = com.pulseloop.util.TimeUtil.startOfTodayLocal()
if (prefs.lastBackfillDay != today) {
if (manager.refreshHistoricalDays()) {
prefs.lastBackfillDay = today

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cold-start race. On a cold launch, onResume calls refreshPhoneSteps before PulseLoopApp's LaunchedEffect has run persistence.start(). PulseEventBus is a replay = 0 SharedFlow, so today's PhoneStepsUpdate and all 30 backfill events are dropped when nobody is subscribed yet. refreshHistoricalDays() still returns true, so lastBackfillDay = today is written and the backfill won't retry until tomorrow.

}
}
}
}
}
Loading