Skip to content

Add structural Health Connect export for Phoenix workouts#516

Merged
9thLevelSoftware merged 2 commits into
mainfrom
feat/513-structural-logging
Jun 7, 2026
Merged

Add structural Health Connect export for Phoenix workouts#516
9thLevelSoftware merged 2 commits into
mainfrom
feat/513-structural-logging

Conversation

@9thLevelSoftware

Copy link
Copy Markdown
Owner

Summary

  • Upgrade Android Health Connect to 1.2.0-alpha04 and export Phoenix workouts as one session with per-set ExerciseSegments.
  • Keep iOS on aggregate-only HealthKit writes, since the public write API does not support equivalent strength segments.
  • Add persisted-session/export mappers, repository accessors, historical backfill, and Integrations UI actions for retry permissions and sync previous workouts.

Testing

  • Passed :shared:testAndroidHostTest
  • Passed :shared:compileKotlinIosArm64
  • Passed :shared:verifyCommonMainVitruvianDatabaseMigration
  • Passed :shared:checkKotlinAbi
  • Passed :androidApp:assembleDebug
  • spotlessCheck still fails on a pre-existing unrelated formatting issue in OAuthRedirectActivity.kt

Copilot AI review requested due to automatic review settings June 7, 2026 20:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

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.

Suggested change
val fallbackType = ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING
val fallbackType = ExerciseSegment.EXERCISE_SEGMENT_TYPE_UNKNOWN

Comment on lines +204 to +210
"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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

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.

Suggested change
"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

Comment on lines +99 to +113
): 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()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

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)
        }

Comment on lines +42 to +44
val completedSetsBySessionId = completedSetRepository
.getCompletedSetsForSessions(sessions.map { it.id })
.groupBy { it.sessionId }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

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.

Suggested change
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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)
        }

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +50 to +51
workouts.forEach { workout ->
healthIntegration.writeHealthWorkout(workout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@9thLevelSoftware 9thLevelSoftware merged commit 615dd7d into main Jun 7, 2026
10 checks passed
@9thLevelSoftware 9thLevelSoftware deleted the feat/513-structural-logging branch June 7, 2026 23:07
9thLevelSoftware pushed a commit that referenced this pull request Jun 7, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Google Health Connect – structural logging for complex strength training and routines

2 participants