Add structural Health Connect export for Phoenix workouts#516
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the health integration system to support rich workout data exports with set-level segments on Android and aggregate data on iOS, alongside a new backfill manager to sync previous workouts. The code review highlights several critical issues that must be addressed: compilation failures on Android due to the use of the non-existent ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING constant, a runtime crash caused by overlapping exercise segment times, a potential SQLite exception during backfill when querying more than 999 sessions, and overly fragile error handling that fails the entire backfill process if only a single workout fails to write.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| private fun HealthWorkoutSegment.toHealthConnectSegment(): ExerciseSegment { | ||
| val sessionType = ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING | ||
| val preferredType = segmentTypeForExercise(exerciseName) | ||
| val fallbackType = ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING |
There was a problem hiding this comment.
The constant ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING does not exist in the Android Health Connect SDK. Weightlifting is an exercise session type (ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING), not an individual segment type. Using this non-existent constant will cause a compilation failure. Please use ExerciseSegment.EXERCISE_SEGMENT_TYPE_UNKNOWN as the fallback type.
| val fallbackType = ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING | |
| val fallbackType = ExerciseSegment.EXERCISE_SEGMENT_TYPE_UNKNOWN |
| "row" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING | ||
| "pulldown" in normalized || "pull down" in normalized || "lat pull" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING | ||
| "pull-up" in normalized || "pull up" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_PULL_UP | ||
| "lunge" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_LUNGE | ||
| "shoulder press" in normalized || "overhead press" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_SHOULDER_PRESS | ||
| "tricep" in normalized || "triceps" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_DOUBLE_ARM_TRICEPS_EXTENSION | ||
| else -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING |
There was a problem hiding this comment.
As noted, ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING is not a valid segment type constant. Please map these generic strength exercises to ExerciseSegment.EXERCISE_SEGMENT_TYPE_UNKNOWN to prevent compilation errors.
| "row" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING | |
| "pulldown" in normalized || "pull down" in normalized || "lat pull" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING | |
| "pull-up" in normalized || "pull up" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_PULL_UP | |
| "lunge" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_LUNGE | |
| "shoulder press" in normalized || "overhead press" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_SHOULDER_PRESS | |
| "tricep" in normalized || "triceps" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_DOUBLE_ARM_TRICEPS_EXTENSION | |
| else -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING | |
| "row" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_UNKNOWN | |
| "pulldown" in normalized || "pull down" in normalized || "lat pull" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_UNKNOWN | |
| "pull-up" in normalized || "pull up" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_PULL_UP | |
| "lunge" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_LUNGE | |
| "shoulder press" in normalized || "overhead press" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_SHOULDER_PRESS | |
| "tricep" in normalized || "triceps" in normalized -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_DOUBLE_ARM_TRICEPS_EXTENSION | |
| else -> ExerciseSegment.EXERCISE_SEGMENT_TYPE_UNKNOWN |
| ): List<HealthWorkoutSegment> = sessions | ||
| .sortedBy { it.timestamp } | ||
| .flatMap { session -> | ||
| val completedSets = completedSetsBySessionId[session.id] | ||
| .orEmpty() | ||
| .sortedWith(compareBy<CompletedSet> { it.setNumber }.thenBy { it.completedAt }) | ||
|
|
||
| if (completedSets.isNotEmpty()) { | ||
| completedSets.mapNotNull { completedSet -> | ||
| buildSegment(session, completedSet) | ||
| } | ||
| } else { | ||
| buildSegment(session, completedSet = null)?.let(::listOf).orEmpty() | ||
| } | ||
| } |
There was a problem hiding this comment.
Android Health Connect strictly prohibits overlapping ExerciseSegments within an ExerciseSessionRecord. Because buildSegment calculates segment times independently using session.duration (the entire exercise duration), multiple sets within the same session will heavily overlap. This will cause a runtime IllegalArgumentException on insertion. Adjust the segment times sequentially to ensure they are strictly non-overlapping.
val rawSegments = sessions
.sortedBy { it.timestamp }
.flatMap { session ->
val completedSets = completedSetsBySessionId[session.id]
.orEmpty()
.sortedWith(compareBy<CompletedSet> { it.setNumber }.thenBy { it.completedAt })
if (completedSets.isNotEmpty()) {
completedSets.mapNotNull { completedSet ->
buildSegment(session, completedSet)
}
} else {
buildSegment(session, completedSet = null)?.let(::listOf).orEmpty()
}
}
var lastEndTime = 0L
return rawSegments.map { segment ->
val adjustedStart = maxOf(segment.startTimeMs, lastEndTime)
val adjustedEnd = maxOf(segment.endTimeMs, adjustedStart + 1000L)
lastEndTime = adjustedEnd
segment.copy(startTimeMs = adjustedStart, endTimeMs = adjustedEnd)
}| val completedSetsBySessionId = completedSetRepository | ||
| .getCompletedSetsForSessions(sessions.map { it.id }) | ||
| .groupBy { it.sessionId } |
There was a problem hiding this comment.
Querying completed sets for all candidate sessions at once using an IN clause can trigger a SQLite exception (too many SQL variables) if the user has more than 999 candidate sessions. To prevent production crashes for long-time users, chunk the session IDs into batches of 500 before querying.
| val completedSetsBySessionId = completedSetRepository | |
| .getCompletedSetsForSessions(sessions.map { it.id }) | |
| .groupBy { it.sessionId } | |
| val completedSetsBySessionId = sessions.map { it.id } | |
| .chunked(500) | |
| .flatMap { chunk -> completedSetRepository.getCompletedSetsForSessions(chunk) } | |
| .groupBy { it.sessionId } |
| skippedWorkouts = (workouts.size - written).coerceAtLeast(0), | ||
| ) | ||
|
|
||
| return firstFailure?.let { Result.failure(it) } ?: Result.success(result) |
There was a problem hiding this comment.
Returning a failure for the entire backfill operation if a single workout fails to write is too fragile. If 99 out of 100 workouts succeed, the operation is still marked as a failure, which sets the overall integration status to ConnectionStatus.ERROR in IntegrationsViewModel. Consider failing only if all writes fail, and succeeding otherwise.
return if (written == 0 && workouts.isNotEmpty()) {
Result.failure(firstFailure ?: IllegalStateException("All backfill writes failed"))
} else {
Result.success(result)
}There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f9c728ce5d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| workouts.forEach { workout -> | ||
| healthIntegration.writeHealthWorkout(workout) |
There was a problem hiding this comment.
Avoid re-exporting already synced health workouts
On Apple Health, pressing “Sync previous” after workouts have already been auto-pushed, or pressing it a second time, runs this loop over the same candidates and saves them again; the new backfill path does not filter by an export marker/cutoff or query existing health records, and the iOS writer just calls saveObject with metadata, which does not make HKExternalUUID unique. This leaves duplicate workouts in Health for normal connected users who use the backfill action.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1a20e4859
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ) | ||
| _uiState.value = _uiState.value.copy(errorMessage = "Health sync failed: ${e.message}") | ||
| } finally { | ||
| setOperationLoading(provider, "health_backfill", false) |
There was a problem hiding this comment.
Preserve health sync errors after clearing loading state
When the health backfill fails, the failure branch sets errorMessage, but the finally block immediately calls setOperationLoading(..., false), and that helper always rewrites errorMessage = null. In the sync-previous error path this can clear the failure before the screen's LaunchedEffect(uiState.errorMessage) has a chance to show the snackbar, leaving users with no visible feedback even though the provider was moved to ERROR.
Useful? React with 👍 / 👎.
Resolve merge conflicts with weight recommendation (PR #499), timed-set countdown cues (PR #505), and Health Connect export (PR #516) by keeping both deload modifiers and the new main features. https://claude.ai/code/session_01MBWwVaUrnXjzZxYKKnn33i
Summary
1.2.0-alpha04and export Phoenix workouts as one session with per-setExerciseSegments.Testing
:shared:testAndroidHostTest:shared:compileKotlinIosArm64:shared:verifyCommonMainVitruvianDatabaseMigration:shared:checkKotlinAbi:androidApp:assembleDebugspotlessCheckstill fails on a pre-existing unrelated formatting issue inOAuthRedirectActivity.kt