Skip to content

Implement audit remediation for BLE, sync, backup, and config hygiene#494

Merged
9thLevelSoftware merged 6 commits into
mainfrom
codex/battle-hardening-pre-launch
Jun 5, 2026
Merged

Implement audit remediation for BLE, sync, backup, and config hygiene#494
9thLevelSoftware merged 6 commits into
mainfrom
codex/battle-hardening-pre-launch

Conversation

@9thLevelSoftware

Copy link
Copy Markdown
Owner

Summary

  • Add secret-tracking guards, CI hygiene checks, iOS Supabase setup guidance, and a tracked-file cleanup for the real config.
  • Harden BLE lifecycle/cancellation paths, add shutdown cleanup, critical event drop tracking, and upfront workout command validation.
  • Add sync invariant warning checks, backup privacy metadata, and clearer backup vs diagnostics copy.
  • Refresh audit/test documentation and keep the remediation gated by repository checks.

Testing

  • :shared:testAndroidHostTest targeted regression coverage for BLE cancellation, packet validation, sync invariants, backup serialization, and diagnostics export text.
  • spotlessCheck, validateSchemaManifest, :shared:verifyCommonMainVitruvianDatabaseMigration, and :shared:testAndroidHostTest all passed in the full local gate.
  • Windows and Bash secret-file guard scripts passed, and git diff --check was clean apart from CRLF normalization warnings.

Copilot AI review requested due to automatic review settings June 4, 2026 18:53

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 implements security, configuration, and BLE lifecycle guardrails, including secret-scanning scripts, a new WorkoutCommandValidator, a SyncInvariantChecker for data sync validation, and privacy metadata for backups. The reviewer provided valuable feedback on ensuring thread safety in the BLE event tracker, securing state cleanup during connection teardown with try-finally blocks, preventing potential null pointer exceptions and false-positive warnings in the invariant checker, using timezone-safe ISO-8601 formatting for backup timestamps, and improving the shell script's robustness when handling file paths with spaces.

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.

Comment on lines +25 to +37
fun recordDropped(type: BleCriticalEventType) {
_snapshot.value = when (type) {
BleCriticalEventType.REP -> _snapshot.value.copy(repEventsDropped = _snapshot.value.repEventsDropped + 1)

BleCriticalEventType.DELOAD -> _snapshot.value.copy(deloadEventsDropped = _snapshot.value.deloadEventsDropped + 1)

BleCriticalEventType.ROM_VIOLATION ->
_snapshot.value.copy(romViolationEventsDropped = _snapshot.value.romViolationEventsDropped + 1)

BleCriticalEventType.RECONNECTION_REQUEST ->
_snapshot.value.copy(reconnectionRequestsDropped = _snapshot.value.reconnectionRequestsDropped + 1)
}
}

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

The recordDropped function is not thread-safe. Since BLE events can be received concurrently from different characteristics or connection callbacks, updating _snapshot.value directly using a read-modify-write pattern can lead to race conditions and lost updates. Use _snapshot.update { ... } from kotlinx.coroutines.flow to ensure atomic, thread-safe updates.

    fun recordDropped(type: BleCriticalEventType) {
        _snapshot.update { current ->
            when (type) {
                BleCriticalEventType.REP -> current.copy(repEventsDropped = current.repEventsDropped + 1)
                BleCriticalEventType.DELOAD -> current.copy(deloadEventsDropped = current.deloadEventsDropped + 1)
                BleCriticalEventType.ROM_VIOLATION -> current.copy(romViolationEventsDropped = current.romViolationEventsDropped + 1)
                BleCriticalEventType.RECONNECTION_REQUEST -> current.copy(reconnectionRequestsDropped = current.reconnectionRequestsDropped + 1)
            }
        }
    }

Comment on lines +426 to +448
suspend fun shutdown() {
log.i { "Shutting down BLE connection manager" }
isExplicitDisconnect = true

scanJob?.cancel()
scanJob = null
stateObserverJob?.cancel()
stateObserverJob = null
pollingEngine.stopAll()
discoMode.shutdown()
handleDetector.disable()
handleDetector.reset()

try {
peripheral?.disconnect()
} catch (e: Exception) {
e.rethrowIfCancellation()
log.w { "Shutdown disconnect error (non-fatal): ${e.message}" }
}

clearConnectionState(clearScannedDevices = true)
reportConnectionState(ConnectionState.Disconnected)
}

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

If peripheral?.disconnect() throws a CancellationException (which is rethrown by e.rethrowIfCancellation()), the subsequent cleanup calls clearConnectionState and reportConnectionState will be skipped. This leaves the connection manager in an inconsistent state. Wrap the disconnect call in a try-catch block and place the state clearing and reporting in a finally block to ensure they always run.

    suspend fun shutdown() {
        log.i { "Shutting down BLE connection manager" }
        isExplicitDisconnect = true

        scanJob?.cancel()
        scanJob = null
        stateObserverJob?.cancel()
        stateObserverJob = null
        pollingEngine.stopAll()
        discoMode.shutdown()
        handleDetector.disable()
        handleDetector.reset()

        try {
            peripheral?.disconnect()
        } catch (e: Exception) {
            e.rethrowIfCancellation()
            log.w { "Shutdown disconnect error (non-fatal): ${e.message}" }
        } finally {
            clearConnectionState(clearScannedDevices = true)
            reportConnectionState(ConnectionState.Disconnected)
        }
    }

Comment on lines +176 to +183
override suspend fun shutdown() {
log.i { "Shutting down BLE repository" }
connectionManager.shutdown()
_scannedDevices.value = emptyList()
_heuristicData.value = null
clearDiagnostics()
repositoryJob.cancel()
}

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

Similar to the connection manager, if connectionManager.shutdown() throws a CancellationException or other exception, the repository's local state clearing and repositoryJob.cancel() will be skipped. Wrap the shutdown call in a try-finally block to guarantee that the repository scope is cancelled and state is cleared.

    override suspend fun shutdown() {
        log.i { "Shutting down BLE repository" }
        try {
            connectionManager.shutdown()
        } finally {
            _scannedDevices.value = emptyList()
            _heuristicData.value = null
            clearDiagnostics()
            repositoryJob.cancel()
        }
    }

Comment on lines +140 to +148
.groupBy {
listOf(
it.exerciseName.trim().lowercase(),
it.recordType.trim().lowercase(),
it.workoutPhase.orEmpty().trim().lowercase(),
it.achievedAt.orEmpty().trim(),
it.sessionId.orEmpty().trim(),
).joinToString("|")
}

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

If recordType is nullable and defaults to null when not provided (as seen in some test cases), calling it.recordType.trim() directly will throw a NullPointerException during grouping. Use orEmpty() to safely handle null values.

        personalRecords
            .filter { it.exerciseName.isNotBlank() && !it.achievedAt.isNullOrBlank() }
            .groupBy {
                listOf(
                    it.exerciseName.trim().lowercase(),
                    it.recordType.orEmpty().trim().lowercase(),
                    it.workoutPhase.orEmpty().trim().lowercase(),
                    it.achievedAt.orEmpty().trim(),
                    it.sessionId.orEmpty().trim(),
                ).joinToString("|")
            }

Comment on lines +43 to +48
if (exercise.sessionId.isNotBlank() && exercise.sessionId != session.id) {
violations += SyncInvariantViolation(
code = "ORPHAN_PULL_EXERCISE",
entityId = exercise.id,
message = "Exercise ${exercise.id} references session ${exercise.sessionId}, expected ${session.id}",
)

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

UUID casing can vary across platforms (e.g., iOS often generates uppercase UUIDs, while Android and backends default to lowercase). Comparing them directly with != can lead to false-positive orphan warnings. Use .equals(..., ignoreCase = true) for case-insensitive ID comparisons.

Suggested change
if (exercise.sessionId.isNotBlank() && exercise.sessionId != session.id) {
violations += SyncInvariantViolation(
code = "ORPHAN_PULL_EXERCISE",
entityId = exercise.id,
message = "Exercise ${exercise.id} references session ${exercise.sessionId}, expected ${session.id}",
)
if (exercise.sessionId.isNotBlank() && !exercise.sessionId.equals(session.id, ignoreCase = true)) {
violations += SyncInvariantViolation(
code = "ORPHAN_PULL_EXERCISE",
entityId = exercise.id,
message = "Exercise ${exercise.id} references session ${exercise.sessionId}, expected ${session.id}",
)
}

Comment on lines 1752 to 1754
val exportNowMs = KmpUtils.currentTimeMillis()
val exportedAt = KmpUtils.formatTimestamp(exportNowMs, "yyyy-MM-dd") + "T" +
KmpUtils.formatTimestamp(exportNowMs, "HH:mm:ss") + "Z"

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

Using manual string formatting and appending 'Z' can lead to incorrect timezone representations if KmpUtils.formatTimestamp formats in local time instead of UTC. A safer, cleaner, and standard way in kotlinx.datetime is to use Instant.fromEpochMilliseconds(KmpUtils.currentTimeMillis()).toString(), which automatically formats to a timezone-safe ISO-8601 UTC string.

Suggested change
val exportNowMs = KmpUtils.currentTimeMillis()
val exportedAt = KmpUtils.formatTimestamp(exportNowMs, "yyyy-MM-dd") + "T" +
KmpUtils.formatTimestamp(exportNowMs, "HH:mm:ss") + "Z"
val exportedAt = kotlinx.datetime.Instant.fromEpochMilliseconds(KmpUtils.currentTimeMillis()).toString()

Comment on lines +7 to +39
while IFS= read -r raw_path; do
path="${raw_path//\\//}"
lower="$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')"
is_template=false
case "$lower" in
*.example|*.sample|*.template|*/templates/*) is_template=true ;;
esac

if [[ "$path" =~ (^|/)Supabase\.xcconfig$ ]]; then
violations+=("$path (real iOS Supabase config must stay local or be generated from CI secrets)")
continue
fi

if [[ "$path" =~ (^|/).*\.local\.properties$ || "$path" =~ (^|/)local\.properties$ ]]; then
violations+=("$path (local Gradle/SDK or secret properties must not be tracked)")
continue
fi

if [[ "$is_template" == false && ( "$path" =~ (^|/)google-services\.json$ || "$path" =~ (^|/)GoogleService-Info\.plist$ ) ]]; then
violations+=("$path (service config must be supplied outside git unless it is a template)")
continue
fi

if [[ "$is_template" == false && "$path" =~ \.(jks|keystore|p12|mobileprovision)$ ]]; then
violations+=("$path (signing material must be supplied from a local machine or CI secret)")
continue
fi

if [[ "$is_template" == false && "$path" =~ (^|/)\.env(\..*)?$ ]]; then
violations+=("$path (environment files must not be tracked)")
continue
fi
done < <(git -C "$repo_root" ls-files)

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

Reading file paths line-by-line using while read without -d '' can fail or behave unexpectedly if any tracked file names contain spaces, newlines, or special characters. It is safer and more robust to use git ls-files -z paired with read -r -d '' to handle null-terminated file paths.

while IFS= read -r -d '' raw_path; do
  path="${raw_path//\\//}"
  lower="$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')"
  is_template=false
  case "$lower" in
    *.example|*.sample|*.template|*/templates/*) is_template=true ;;
  esac

  if [[ "$path" =~ (^|/)Supabase\.xcconfig$ ]]; then
    violations+=("$path (real iOS Supabase config must stay local or be generated from CI secrets)")
    continue
  fi

  if [[ "$path" =~ (^|/).*\.local\.properties$ || "$path" =~ (^|/)local\.properties$ ]]; then
    violations+=("$path (local Gradle/SDK or secret properties must not be tracked)")
    continue
  fi

  if [[ "$is_template" == false && ( "$path" =~ (^|/)google-services\.json$ || "$path" =~ (^|/)GoogleService-Info\.plist$ ) ]]; then
    violations+=("$path (service config must be supplied outside git unless it is a template)")
    continue
  fi

  if [[ "$is_template" == false && "$path" =~ \.(jks|keystore|p12|mobileprovision)$ ]]; then
    violations+=("$path (signing material must be supplied from a local machine or CI secret)")
    continue
  fi

  if [[ "$is_template" == false && "$path" =~ (^|/)\.env(\..*)?$ ]]; then
    violations+=("$path (environment files must not be tracked)")
    continue
  fi
done < <(git -C "$repo_root" ls-files -z)

@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: f8907f9578

ℹ️ 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".

private fun isFinite(value: Float): Boolean = !value.isNaN() && !value.isInfinite()

private fun validateWeightRange(weightPerCableKg: Float, allowZero: Boolean): Result<Unit> {
val min = if (allowZero) Constants.MIN_WEIGHT_KG else Constants.JUST_LIFT_MIN_VALID_WEIGHT_KG

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 Allow valid fractional routine weights

For normal non-AMRAP workouts this makes the minimum BLE-sendable weight 1 kg by reusing the Just Lift race-condition guard, but the app still exposes and stores 0.5 kg increments (WEIGHT_INCREMENT_OPTIONS_KG/DEFAULT_WEIGHT_INCREMENT_KG) and updateSetReadyWeight clamps routine weights from MIN_WEIGHT_KG (0) to the hardware max. A user can therefore configure a regular routine/set at 0.5 kg per cable, then validateProgramParams/legacy validation rejects it before sending the workout command, preventing an otherwise valid lightweight/fractional set from starting. Use the general minimum for non-Just-Lift commands and keep the 1 kg guard scoped to Just Lift.

Useful? React with 👍 / 👎.

@kilo-code-bot

kilo-code-bot Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Code Review Roast 🔥

Verdict: No Issues Found | Recommendation: Merge

Another round of cleanup and this PR stays pristine. I'm starting to think the author has achieved some zen-like code harmony.

Incremental Diff Analysis

File Change Assessment
VitruvianDatabase.sq:517 SQL query now filters isTutorial = 0 Correct — database-level exclusion of tutorial videos
ExerciseImporter.kt:223,366 Removed tutorial video insertion, added explanatory comments Clean — intentional design decision
MigrationStatements.kt:843-846 Added migration 32 for tutorial cleanup Correct — handles existing data
32.sqm New migration file Clean
AssessmentViewModel.kt:133 demoVideos = videos.filterNot { it.isTutorial } Defensive (redundant with SQL filter) but not harmful
AssessmentWizardScreen.kt:243 Changed to { !it.isTutorial } filter Correct — aligns with new design
WorkoutHud.kt:84,172-215 Removed InstructionPage and video page (pager 3→2) Correct — simplifies UI
WorkoutTab.kt:316-324 Removed exerciseRepository/enableVideoPlayback params Correct — matches new signature
SqlDelightExerciseRepositoryTest.kt:114-172 Added tests for tutorial exclusion Good — validates the behavior
exercise_dump.json Tutorial fields set to null Data cleanup

📊 Overall: Like a well-maintained codebase that actually respects its own conventions. No issues found in this increment.

Files Reviewed (9 files in incremental diff)
  • shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq — SQL filter
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/ExerciseImporter.kt — tutorial exclusion
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt — migration registration
  • shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/32.sqm — cleanup migration
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/AssessmentViewModel.kt — video filtering
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/AssessmentWizardScreen.kt — video selection logic
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt — removed video page
  • shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt — parameter cleanup
  • shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightExerciseRepositoryTest.kt — test coverage
  • shared/src/commonMain/composeResources/files/exercise_dump.json — data updates

🏆 Best part: SQL-level filtering (isTutorial = 0) ensures tutorial videos are excluded at the source, making the data layer the single source of truth.

📊 Overall: Clean increment. The author's attention to defensive programming (double filtering) shows caution, even if it's redundant with the SQL filter. Everything works as intended.


Reviewed by laguna-m.1-20260312:free · 3,520,736 tokens

@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: 267e08db99

ℹ️ 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".

writer.write("""{"version":$CURRENT_BACKUP_VERSION,"exportedAt":"$exportedAt","appVersion":"${Constants.APP_VERSION}","data":{""")
val exportedAt = kotlin.time.Instant.fromEpochMilliseconds(KmpUtils.currentTimeMillis()).toString()
val privacy = json.encodeToString(BackupPrivacyMetadata.serializer(), BackupPrivacyMetadata())
writer.write("""{"version":$CURRENT_BACKUP_VERSION,"exportedAt":"$exportedAt","appVersion":"${Constants.APP_VERSION}","privacy":$privacy,"data":{""")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Skip privacy metadata before streaming import

When streaming export writes privacy before data, the streaming importer cannot read these new backups because its top-level parser only handles version, exportedAt, appVersion, and data and does not skip unknown fields. For large backups (or content URIs with unknown size) that use importFromStream, after nav.nextName() reads privacy the next loop attempts to parse the privacy object as a field name and fails before reaching data. Add a privacy -> nav.skipValue() branch or a default skip for unknown top-level fields.

Useful? React with 👍 / 👎.

@9thLevelSoftware 9thLevelSoftware merged commit 583d01e into main Jun 5, 2026
10 checks passed
@9thLevelSoftware 9thLevelSoftware deleted the codex/battle-hardening-pre-launch branch June 5, 2026 20:58
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.

2 participants