From fa36e44966dc4102587c49f19d15a9e9eab3ac5b Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 29 Oct 2026 18:39:30 -0400 Subject: [PATCH 1/3] chore: update dependencies, build configuration, and code style - Upgrade Gradle wrapper to 9.8.2, AGP to 9.5.0, Kotlin to 2.4.21, Compose Multiplatform to 1.12.2, and related multiplatform library dependencies. - Enable parallel Gradle tooling sync in `gradle.properties`. - Add local placeholder fallbacks for Supabase configuration in debug/sync builds while enforcing credential validation for release build tasks. - Standardize `Logger` calls across manager and domain classes to explicitly use named `tag` parameters. - Remove redundant `public` visibility modifiers in `kable-core-tvos`. - Simplify boolean expressions, return statements, and `when` subject assignments across session managers, JSON parser utilities, and domain use cases. --- androidApp/build.gradle.kts | 47 +++++++++++------ gradle.properties | 3 ++ gradle/libs.versions.toml | 40 +++++++------- gradle/wrapper/gradle-wrapper.properties | 5 +- .../domain/model/BiomechanicsModels.kt | 2 +- .../domain/premium/BiomechanicsEngine.kt | 2 +- .../domain/premium/RepQualityScorer.kt | 2 +- .../domain/premium/SmartSuggestionsEngine.kt | 3 +- .../RecommendWeightAdjustmentUseCase.kt | 11 ++-- .../domain/usecase/RepCounterFromMachine.kt | 6 +-- .../usecase/RoutineSetWeightResolver.kt | 2 +- .../domain/voice/SafeWordDetectionManager.kt | 12 ++--- .../manager/ActiveSessionEngine.kt | 45 ++++++++-------- .../manager/DefaultWorkoutSessionManager.kt | 19 ++----- .../presentation/manager/HistoryManager.kt | 2 +- .../manager/MachineSafetyCoordinator.kt | 11 ++-- .../manager/RoutineFlowManager.kt | 27 +++++----- .../presentation/tv/TvSessionController.kt | 5 +- .../util/BackupJsonNavigator.kt | 15 ++---- .../phoenixproject/util/BlePacketFactory.kt | 52 +++++++++++-------- .../phoenixproject/util/DataBackupManager.kt | 3 +- .../src/appleMain/kotlin/ApplePeripheral.kt | 2 +- .../src/appleMain/kotlin/Bluetooth.kt | 2 +- .../src/appleMain/kotlin/CentralManager.kt | 12 ++--- .../kotlin/CoreBluetoothAdvertisement.kt | 2 +- .../kotlin/CoreBluetoothPeripheral.kt | 12 ++--- .../appleMain/kotlin/CoreBluetoothScanner.kt | 2 +- .../src/appleMain/kotlin/Identifier.kt | 4 +- .../appleMain/kotlin/Peripheral.deprecated.kt | 6 +-- .../src/appleMain/kotlin/Peripheral.kt | 6 +-- .../src/appleMain/kotlin/PeripheralBuilder.kt | 22 ++++---- .../appleMain/kotlin/PlatformAdvertisement.kt | 8 +-- .../src/appleMain/kotlin/ScannerBuilder.kt | 12 ++--- .../appleMain/kotlin/logs/SystemLogEngine.kt | 2 +- 34 files changed, 202 insertions(+), 204 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 076eec4cf..f5b721020 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -104,9 +104,15 @@ abstract class VerifySupabaseRuntimeConfigTask : DefaultTask() { @TaskAction fun verifyRuntimeConfig() { - val uri = validateSupabaseUrl(configuredSupabaseUrl.get()) + val url = configuredSupabaseUrl.get() + val uri = validateSupabaseUrl(url) val host = checkNotNull(uri.host).lowercase() + if (host == "placeholder.supabase.co") { + println("Android Supabase config using local placeholder: host=$host") + return + } + val addresses = InetAddress.getAllByName(host) println("Android Supabase config verified: host=$host, dnsAddresses=${addresses.size}") } @@ -136,22 +142,19 @@ val localPropsMap: Map = if (localPropsFile.exists()) { } // Priority: local.properties > environment variables -val supabaseUrl: String = localPropsMap["supabase.url"]?.takeIf { it.isNotBlank() } +val rawSupabaseUrl: String = localPropsMap["supabase.url"]?.takeIf { it.isNotBlank() } ?: System.getenv("SUPABASE_URL") ?: "" -val supabaseAnonKey: String = localPropsMap["supabase.anon.key"]?.takeIf { it.isNotBlank() } +val rawSupabaseAnonKey: String = localPropsMap["supabase.anon.key"]?.takeIf { it.isNotBlank() } ?: System.getenv("SUPABASE_ANON_KEY") ?: "" -// Validate Supabase credentials unless explicitly skipped (e.g., CI builds that only run tests) +// Fall back to placeholder credentials for local debug builds and Gradle Sync if missing +val supabaseUrl: String = rawSupabaseUrl.ifBlank { "https://placeholder.supabase.co" } +val supabaseAnonKey: String = rawSupabaseAnonKey.ifBlank { "placeholder-anon-key" } + +// Validate Supabase URL format unless explicitly skipped val skipSupabaseCheck = providers.gradleProperty("skip.supabase.check").orNull?.toBoolean() ?: false -if (!skipSupabaseCheck && (supabaseUrl.isBlank() || supabaseAnonKey.isBlank())) { - throw GradleException( - "Missing Supabase credentials. Provide via local.properties (supabase.url, supabase.anon.key) " + - "or environment variables (SUPABASE_URL, SUPABASE_ANON_KEY), " + - "or pass -Pskip.supabase.check=true for test-only builds.", - ) -} if (!skipSupabaseCheck && supabaseUrl.isNotBlank()) { VerifySupabaseRuntimeConfigTask.validateSupabaseUrl(supabaseUrl) } @@ -249,12 +252,22 @@ val releasePackagingTaskNames = setOf( "zipApksForRelease", ) tasks.configureEach { - if (name in releasePackagingTaskNames && injectedVersionCode == null) { - doFirst { - throw GradleException( - "version.code property must be set for release builds. " + - "Pass -Pversion.code= (e.g., ./gradlew assembleRelease -Pversion.code=42).", - ) + if (name in releasePackagingTaskNames) { + if (injectedVersionCode == null) { + doFirst { + throw GradleException( + "version.code property must be set for release builds. " + + "Pass -Pversion.code= (e.g., ./gradlew assembleRelease -Pversion.code=42).", + ) + } + } + if (rawSupabaseUrl.isBlank() || rawSupabaseAnonKey.isBlank()) { + doFirst { + throw GradleException( + "Supabase credentials (supabase.url and supabase.anon.key) must be provided " + + "via local.properties or environment variables (SUPABASE_URL, SUPABASE_ANON_KEY) for release builds.", + ) + } } } } diff --git a/gradle.properties b/gradle.properties index 8dd3a3e65..066e6d660 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,3 +35,6 @@ kotlin.native.ignoreDisabledTargets=true # Disable default hierarchy template (using explicit dependsOn for iOS) kotlin.mpp.applyDefaultHierarchyTemplate=false kotlin.daemon.jvmargs=-Xmx12g + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79064d877..26ceaf622 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,64 +1,64 @@ [versions] # Kotlin and KMP -kotlin = "2.4.0" -agp = "9.2.1" -compose-multiplatform = "1.11.1" +kotlin = "2.4.21" +agp = "9.5.0" +compose-multiplatform = "1.12.2" # material3 and material-icons follow separate version tracks from CMP plugin cmp-material3 = "1.10.0-alpha05" cmp-material-icons = "1.7.3" # Kotlinx kotlinx-coroutines = "1.11.0" -kotlinx-serialization = "1.11.0" +kotlinx-serialization = "1.12.0" kotlinx-datetime = "0.8.0" # Compose activity-compose = "1.13.0" # Vendored Kable module deps (third_party/kable-core-android-patched, Phoenix #333) -kotlinx-io = "0.8.0" -atomicfu = "0.29.0" -tuulbox = "8.1.0" +kotlinx-io = "0.9.1" +atomicfu = "0.33.0" +tuulbox = "8.1.1" androidx-startup = "1.2.0" kable-atomicfu = "0.33.0" -kable-kotlinx-io = "0.9.0" +kable-kotlinx-io = "0.9.1" # AndroidX junit-androidx = "1.3.0" -androidx-core = "1.19.0" -androidx-lifecycle-viewmodel-compose = "2.10.0" -androidx-navigation-compose = "2.9.2" +androidx-core = "1.19.1" +androidx-lifecycle-viewmodel-compose = "2.11.0" +androidx-navigation-compose = "2.10.0" androidx-savedstate = "1.4.0" androidx-security-crypto = "1.1.0" androidx-browser = "1.10.0" androidx-documentfile = "1.1.0" -androidx-health-connect = "1.2.0-alpha04" +androidx-health-connect = "1.2.0-alpha06" # Dependency Injection - Koin (Multiplatform alternative to Hilt) koin = "4.2.2" koin-compose = "4.2.2" # Database - SQLDelight (Multiplatform alternative to Room) -sqldelight = "2.3.2" +sqldelight = "2.4.1" # Settings/Preferences - Multiplatform multiplatform-settings = "1.3.0" # BLE - Kable (Kotlin Multiplatform) -kable = "0.43.1" +kable = "0.45.0" # Ktor -ktor = "3.5.0" +ktor = "3.6.1" # Logging -kermit = "2.1.0" +kermit = "2.2.0" # Image Loading (Multiplatform) -coil = "3.5.0" +coil = "3.6.3" reorderable = "3.1.0" # Lottie (Multiplatform) -compottie = "2.2.2" +compottie = "2.3.3" # Testing junit = "4.13.2" @@ -67,7 +67,7 @@ turbine = "1.2.1" truth = "1.4.5" koin-test = "4.2.2" multiplatform-settings-test = "1.3.0" -robolectric = "4.15.1" +robolectric = "4.17" androidx-test-core = "1.7.0" [libraries] @@ -123,7 +123,7 @@ cmp-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "comp cmp-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "compose-multiplatform" } # androidx-only artifact (CMP publishes no ui-test-manifest); version matches the # androidx compose that CMP 1.11.1 resolves to for the Android target. -androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version = "1.11.2" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version = "1.12.1" } # Koin (Multiplatform DI) koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5dd3c0121..385523654 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ +#Thu Oct 29 17:34:03 EDT 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/BiomechanicsModels.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/BiomechanicsModels.kt index 39932225c..9e08c53b8 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/BiomechanicsModels.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/BiomechanicsModels.kt @@ -111,7 +111,7 @@ data class ForceCurveResult( override fun hashCode(): Int { var result = normalizedForceN.contentHashCode() result = 31 * result + normalizedPositionPct.contentHashCode() - result = 31 * result + (stickingPointPct?.hashCode() ?: 0) + result = 31 * result + stickingPointPct.hashCode() result = 31 * result + strengthProfile.hashCode() result = 31 * result + repNumber return result diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/BiomechanicsEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/BiomechanicsEngine.kt index d8a565847..709f66d83 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/BiomechanicsEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/BiomechanicsEngine.kt @@ -49,7 +49,7 @@ class BiomechanicsEngine(velocityLossThresholdPercent: Float = 20f) { // C2: Thread-safe snapshot list — processRep() runs on Dispatchers.Default while // getSetSummary()/reset() may be called from the main dispatcher - private val repResults = kotlinx.coroutines.flow.MutableStateFlow>(emptyList()) + private val repResults = MutableStateFlow>(emptyList()) // FINDING-56: @Volatile ensures cross-dispatcher visibility — computeVelocity reads on // Dispatchers.Default while reset() writes null from the main dispatcher. @Volatile diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/RepQualityScorer.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/RepQualityScorer.kt index 685b76ddd..588418cf5 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/RepQualityScorer.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/RepQualityScorer.kt @@ -171,7 +171,7 @@ class RepQualityScorer { // F149: use abs(mean) — a negative mean velocity would make CV negative, // pushing the smoothness score above its max (clamped to "perfect"), // scoring a noisy negative-velocity rep as perfectly smooth. - val coeffOfVariation = sqrt(variance) / kotlin.math.abs(mean) + val coeffOfVariation = sqrt(variance) / abs(mean) return (SMOOTHNESS_MAX_POINTS * maxOf(0f, 1f - coeffOfVariation * SMOOTHNESS_CV_MULTIPLIER)) .coerceIn(0f, SMOOTHNESS_MAX_POINTS) } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/SmartSuggestionsEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/SmartSuggestionsEngine.kt index 10e4d8e85..38d428106 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/SmartSuggestionsEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/premium/SmartSuggestionsEngine.kt @@ -347,8 +347,7 @@ object SmartSuggestionsEngine { muscleGroup: String, onUnknownGroup: ((String) -> Unit)? = null, ): MovementCategory { - val normalized = muscleGroup.lowercase().trim() - return when (normalized) { + return when (val normalized = muscleGroup.lowercase().trim()) { "chest", "pecs", "pectorals", "shoulders", "triceps", "front delts", "anterior delts", "side delts", "lateral delts" -> MovementCategory.PUSH "back", "biceps", "lats", "latissimus", "traps", "trapezius", "rear delts", "posterior delts", "rhomboids" -> MovementCategory.PULL diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCase.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCase.kt index 49c3f3d95..46782d269 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCase.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RecommendWeightAdjustmentUseCase.kt @@ -161,12 +161,11 @@ class RecommendWeightAdjustmentUseCase { ) private fun BiomechanicsSetSummary?.hasSevereVelocityLoss(): Boolean { - if (this == null) return false - return totalVelocityLossPercent?.let { it >= SEVERE_VELOCITY_LOSS_PERCENT } == true || - repResults.any { result -> - result.velocity.shouldStopSet || - (result.velocity.estimatedRepsRemaining != null && result.velocity.estimatedRepsRemaining <= 0) - } + return this != null && (totalVelocityLossPercent?.let { it >= SEVERE_VELOCITY_LOSS_PERCENT } == true || + repResults.any { result -> + result.velocity.shouldStopSet || + (result.velocity.estimatedRepsRemaining != null && result.velocity.estimatedRepsRemaining <= 0) + }) } private fun nextHigherIncrement(current: Float, increment: Float): Float { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RepCounterFromMachine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RepCounterFromMachine.kt index 239e57cb3..32de9d2b2 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RepCounterFromMachine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RepCounterFromMachine.kt @@ -848,8 +848,7 @@ data class RepRanges( * Used to determine whether to show cable position indicators. */ fun isCableAActive(minRangeThreshold: Float = 50f): Boolean { - if (minPosA == null || maxPosA == null) return false - return (maxPosA - minPosA) > minRangeThreshold + return !(minPosA == null || maxPosA == null) && (maxPosA - minPosA) > minRangeThreshold } /** @@ -857,8 +856,7 @@ data class RepRanges( * Used to determine whether to show cable position indicators. */ fun isCableBActive(minRangeThreshold: Float = 50f): Boolean { - if (minPosB == null || maxPosB == null) return false - return (maxPosB - minPosB) > minRangeThreshold + return !(minPosB == null || maxPosB == null) && (maxPosB - minPosB) > minRangeThreshold } /** diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RoutineSetWeightResolver.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RoutineSetWeightResolver.kt index a35eeaddd..1fb56273d 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RoutineSetWeightResolver.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/RoutineSetWeightResolver.kt @@ -32,7 +32,7 @@ object RoutineSetWeightResolver { val usesPrPercentage = hasValidPr && percent > 0 val programmedWeight = when { - usesPrPercentage -> request.currentPrKg!! * percent / 100f + usesPrPercentage -> request.currentPrKg * percent / 100f hasValidPr -> exercise.weightPerCableKg else -> exercise.setWeightsPerCableKg.getOrNull(request.setIndex) ?: exercise.weightPerCableKg } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt index 88c568771..cf566efed 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/voice/SafeWordDetectionManager.kt @@ -84,23 +84,23 @@ class SafeWordDetectionManager( fun startForWorkout() { val context = userProfileRepository.activeProfileContext.value as? ActiveProfileContext.Ready if (context == null) { - Logger.w(TAG) { "Profile context is switching, skipping voice stop" } + Logger.w(tag = TAG) { "Profile context is switching, skipping voice stop" } reportUnavailable(SafeWordUnavailableReason.PROFILE_SWITCHING) return } if (!context.preferences.workout.value.voiceStopEnabled) { - Logger.d(TAG) { "Voice stop not enabled, skipping" } + Logger.d(tag = TAG) { "Voice stop not enabled, skipping" } _state.value = SafeWordState.Disabled return } val safeWord = context.localSafety.safeWord if (safeWord.isNullOrBlank()) { - Logger.w(TAG) { "Voice stop enabled but no safe word configured, skipping" } + Logger.w(tag = TAG) { "Voice stop enabled but no safe word configured, skipping" } reportUnavailable(SafeWordUnavailableReason.NOT_CONFIGURED) return } if (!context.localSafety.safeWordCalibrated) { - Logger.w(TAG) { "Voice stop enabled but safe word not calibrated, skipping" } + Logger.w(tag = TAG) { "Voice stop enabled but safe word not calibrated, skipping" } reportUnavailable(SafeWordUnavailableReason.NOT_CALIBRATED) return } @@ -111,7 +111,7 @@ class SafeWordDetectionManager( // F032: do not log the safe word itself — it is user-chosen voice PII. // The platform listeners already redact it in their startup logs; this // manager must too. Log only non-sensitive metadata. - Logger.i(TAG) { "Starting safe word detection for workout (configured, length=${safeWord.length})" } + Logger.i(tag = TAG) { "Starting safe word detection for workout (configured, length=${safeWord.length})" } val newListener = listenerFactory.create(safeWord) listener = newListener @@ -153,7 +153,7 @@ class SafeWordDetectionManager( bridgeJob = null bridgeSupervisor.cancel() listener?.let { - Logger.d(TAG) { "Stopping safe word detection" } + Logger.d(tag = TAG) { "Stopping safe word detection" } it.stopListening() } listener = null diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 20e200fb9..26b9c5fda 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -11306,13 +11306,13 @@ class ActiveSessionEngine( try { val provider = healthProviderForPlatform() val status = externalActivityRepository.getIntegrationStatus(provider, profileId).first() - Logger.i("ActiveSessionEngine") { + Logger.i(tag = "ActiveSessionEngine") { "HEALTH_DEBUG_ROUTINE_PUSH: provider=${provider.key}, status=${status?.status}, " + "routineSessionId=$routineSessionId, completedExercises=$completedExerciseCount, " + "completedSets=${completedSetKeys.size}, skippedExercises=${skippedIndices.size}, calories=${totalCalories ?: -1f}" } if (status?.status != ConnectionStatus.CONNECTED) { - Logger.i("ActiveSessionEngine") { + Logger.i(tag = "ActiveSessionEngine") { "Routine health push skipped: ${provider.displayName} is not connected (status=${status?.status})" } return@launch @@ -11331,14 +11331,14 @@ class ActiveSessionEngine( completedSetsBySessionId = completedSetsBySessionId, ) if (data == null) { - Logger.i("ActiveSessionEngine") { + Logger.i(tag = "ActiveSessionEngine") { "Routine health push skipped: no persisted exportable segments for routineSessionId=$routineSessionId" } return@launch } val cursorRepository = healthExportCursorRepository if (cursorRepository != null && HealthExportMarkers.isExported(cursorRepository, provider, profileId, data.externalId)) { - Logger.i("ActiveSessionEngine") { + Logger.i(tag = "ActiveSessionEngine") { "Routine health push skipped: ${data.externalId} is already marked exported" } return@launch @@ -11349,15 +11349,15 @@ class ActiveSessionEngine( if (cursorRepository != null) { HealthExportMarkers.markExported(cursorRepository, provider, profileId, data.externalId) } - Logger.i("ActiveSessionEngine") { + Logger.i(tag = "ActiveSessionEngine") { "Auto-pushed routine workout to ${provider.displayName}: $routineName (${durationMs / 1000}s, segments=${data.segments.size})" } } .onFailure { e -> - Logger.w("ActiveSessionEngine") { "Routine health push failed (non-fatal): ${e.message}" } + Logger.w(tag = "ActiveSessionEngine") { "Routine health push failed (non-fatal): ${e.message}" } } } catch (e: Exception) { - Logger.w("ActiveSessionEngine") { "Routine health push failed (non-fatal): ${e.message}" } + Logger.w(tag = "ActiveSessionEngine") { "Routine health push failed (non-fatal): ${e.message}" } } finally { coordinator.routineAccumulatedCalories = 0f } @@ -11497,7 +11497,7 @@ class ActiveSessionEngine( // re-prompt outside the routine flow. coordinator._loadedRoutine.value != null if (shouldPromptBodyweightRepEntry) { - Logger.d("ActiveSessionEngine") { + Logger.d(tag = "ActiveSessionEngine") { "Bodyweight set finished; prompting for reps before saving " + "(exercise=${currentExercise.exercise.name}, set=${coordinator._currentSetIndex.value + 1}, " + "hasDuration=${currentExercise.duration?.let { it > 0 } == true})" @@ -11509,7 +11509,7 @@ class ActiveSessionEngine( if (wasBodyweight) { coordinator.bodyweightSetsCompletedInRoutine++ - Logger.d("ActiveSessionEngine") { "Bodyweight set #${coordinator.bodyweightSetsCompletedInRoutine} completed (exercise=${currentExercise?.exercise?.name})" } + Logger.d(tag = "ActiveSessionEngine") { "Bodyweight set #${coordinator.bodyweightSetsCompletedInRoutine} completed (exercise=${currentExercise.exercise?.name})" } } coordinator.previousExerciseWasBodyweight = wasBodyweight @@ -11600,8 +11600,7 @@ class ActiveSessionEngine( // owner. Resolve it once before publishing SetSummary so the // recommendation is visible during the summary window; unresolved // and accepted offers deliberately never inspect a successor. - val persistedPlan = coordinator._restTransitionPlan.value - val normalPlan = when (persistedPlan) { + val normalPlan = when (val persistedPlan = coordinator._restTransitionPlan.value) { is RestTransitionPlan.NormalAdvance -> persistedPlan is RestTransitionPlan.Declined -> persistedPlan.normalAdvance else -> null @@ -11968,7 +11967,7 @@ class ActiveSessionEngine( if (!hasExpectedAuthority(lease, "rest_timer_start")) return val timerJob = scope.launch(start = CoroutineStart.LAZY) { if (!hasExpectedAuthority(lease, "rest_timer_launch")) return@launch - val timerJob = coroutineContext[kotlinx.coroutines.Job] ?: return@launch + val timerJob = coroutineContext[Job] ?: return@launch val routine = coordinator._loadedRoutine.value val currentExercise = routine?.exercises?.getOrNull(coordinator._currentExerciseIndex.value) val exerciseId = currentExercise?.exercise?.id ?: coordinator._workoutParameters.value.selectedExerciseId @@ -12317,7 +12316,7 @@ class ActiveSessionEngine( } if (autoplay) { - Logger.d("ActiveSessionEngine") { "autoplay rest complete: advancing to next set (no BLE stop - already sent at set end)" } + Logger.d(tag = "ActiveSessionEngine") { "autoplay rest complete: advancing to next set (no BLE stop - already sent at set end)" } if (!hasExpectedAuthority(lease, "rest_timer_successor")) return@launch if (isSingleExercise) { advanceToNextSetInSingleExercise(lease) @@ -12792,7 +12791,7 @@ class ActiveSessionEngine( armJustLiftRestDeadline(restSeconds) coordinator.justLiftRestTimerJob = scope.launch { - val timerJob = coroutineContext[kotlinx.coroutines.Job] + val timerJob = coroutineContext[Job] Logger.d("startJustLiftEggTimer: starting $restSeconds s visual countdown") var lastRenderedSecond = restSeconds + 1 @@ -13186,7 +13185,7 @@ class ActiveSessionEngine( coordinator.restTimerJob = null val isJustLift = coordinator._workoutParameters.value.isJustLift - Logger.d("ActiveSessionEngine") { "skipRest: isJustLift=$isJustLift, advancing (no BLE stop - already sent at set end)" } + Logger.d(tag = "ActiveSessionEngine") { "skipRest: isJustLift=$isJustLift, advancing (no BLE stop - already sent at set end)" } if (isJustLift) { // Just Lift rest returns to Idle (ready for next auto-start set), not Completed @@ -13257,7 +13256,7 @@ class ActiveSessionEngine( } else { coordinator._restSecondsRemaining.value += seconds } - Logger.d("ActiveSessionEngine") { "extendRestTime: +${seconds}s, remaining=${coordinator._restSecondsRemaining.value}, original=${coordinator._restOriginalDuration.value}" } + Logger.d(tag = "ActiveSessionEngine") { "extendRestTime: +${seconds}s, remaining=${coordinator._restSecondsRemaining.value}, original=${coordinator._restOriginalDuration.value}" } } /** @@ -13300,7 +13299,7 @@ class ActiveSessionEngine( coordinator.restDeadlineElapsedRealtimeMs = null coordinator._isRestPaused.value = true } - Logger.d("ActiveSessionEngine") { "toggleRestPause: paused=$newPaused" } + Logger.d(tag = "ActiveSessionEngine") { "toggleRestPause: paused=$newPaused" } } /** @@ -13335,7 +13334,7 @@ class ActiveSessionEngine( coordinator._isRestPaused.value = false coordinator._restSecondsRemaining.value = coordinator._restOriginalDuration.value armRestDeadline(coordinator._restOriginalDuration.value) - Logger.d("ActiveSessionEngine") { "resetRestTimer: reset to ${coordinator._restOriginalDuration.value}s" } + Logger.d(tag = "ActiveSessionEngine") { "resetRestTimer: reset to ${coordinator._restOriginalDuration.value}s" } } private fun mutateRestoredPlanOwnedRest( @@ -13727,7 +13726,7 @@ class ActiveSessionEngine( fun pauseExerciseTimer() { if (coordinator._timedExerciseRemainingSeconds.value == null) return coordinator._isExerciseTimerPaused.value = true - Logger.d("ActiveSessionEngine") { "pauseExerciseTimer: paused" } + Logger.d(tag = "ActiveSessionEngine") { "pauseExerciseTimer: paused" } } /** @@ -13736,7 +13735,7 @@ class ActiveSessionEngine( fun resumeExerciseTimer() { if (coordinator._timedExerciseRemainingSeconds.value == null) return coordinator._isExerciseTimerPaused.value = false - Logger.d("ActiveSessionEngine") { "resumeExerciseTimer: resumed" } + Logger.d(tag = "ActiveSessionEngine") { "resumeExerciseTimer: resumed" } } /** @@ -13749,7 +13748,7 @@ class ActiveSessionEngine( if (original <= 0) return coordinator._isExerciseTimerPaused.value = false coordinator._timedExerciseRemainingSeconds.value = original - Logger.d("ActiveSessionEngine") { "resetExerciseTimer: reset to ${original}s" } + Logger.d(tag = "ActiveSessionEngine") { "resetExerciseTimer: reset to ${original}s" } } fun startNextSet() { @@ -13762,7 +13761,7 @@ class ActiveSessionEngine( } return } - Logger.d("ActiveSessionEngine") { "startNextSet: advancing (no BLE stop - already sent at set end)" } + Logger.d(tag = "ActiveSessionEngine") { "startNextSet: advancing (no BLE stop - already sent at set end)" } if (isSingleExerciseMode(coordinator)) { advanceToNextSetInSingleExercise(executionGuard.currentLease) @@ -13785,7 +13784,7 @@ class ActiveSessionEngine( } coordinator.autoStartJob = scope.launch { - val timerJob = coroutineContext[kotlinx.coroutines.Job] + val timerJob = coroutineContext[Job] try { val countdownSeconds = settingsManager.userPreferences.value.autoStartCountdownSeconds for (i in countdownSeconds downTo 1) { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt index 2142754ea..5fca301c7 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt @@ -499,7 +499,7 @@ class DefaultWorkoutSessionManager( isSingleExercise: Boolean, currentExercise: RoutineExercise?, routine: Routine?, - ): String? = routineFlowManager.calculateNextExerciseName(isSingleExercise, currentExercise, routine) + ): String = routineFlowManager.calculateNextExerciseName(isSingleExercise, currentExercise, routine) override fun calculateIsLastExercise(isSingleExercise: Boolean, currentExercise: RoutineExercise?, routine: Routine?): Boolean = routineFlowManager.calculateIsLastExercise(isSingleExercise, currentExercise, routine) override fun clearCycleContext() = routineFlowManager.clearCycleContext() override fun seedRackSelectionForExercise(exerciseIndex: Int) = routineFlowManager.seedRackSelectionForExercise(exerciseIndex) @@ -853,12 +853,8 @@ class DefaultWorkoutSessionManager( // When all exercises in the loaded routine are bodyweight, the Phoenix // trainer is not needed and its auto-power-off should not interrupt the user. val routine = coordinator._loadedRoutine.value - if (routine != null && routine.exercises.isNotEmpty() && - routine.exercises.all { it.exercise.isBodyweight } - ) { - return false - } - return true + return !(routine != null && routine.exercises.isNotEmpty() && + routine.exercises.all { it.exercise.isBodyweight }) } override val isWorkoutMidSet: Boolean @@ -1283,8 +1279,7 @@ class DefaultWorkoutSessionManager( * pre-publish decision in `handleSetCompletion`, where the state is not `SetSummary` yet. */ fun isTerminalRoutineSummary(): Boolean { - if (coordinator._workoutState.value !is WorkoutState.SetSummary) return false - return activeSessionEngine.isTerminalRoutineStep() + return coordinator._workoutState.value is WorkoutState.SetSummary && activeSessionEngine.isTerminalRoutineStep() } // ===== Orchestration: proceedFromSummary (cross-cutting, stays in DWSM) ===== @@ -1489,11 +1484,7 @@ class DefaultWorkoutSessionManager( val currentExercise = it.exercises.getOrNull(coordinator._currentExerciseIndex.value) val isAMRAPExercise = currentExercise?.isAMRAP == true - if (isAMRAPExercise) { - true // AMRAP always has "more sets" - user decides when to move on - } else { - currentExercise != null && coordinator._currentSetIndex.value < currentExercise.setReps.size - 1 - } + isAMRAPExercise || currentExercise != null && coordinator._currentSetIndex.value < currentExercise.setReps.size - 1 // AMRAP always has "more sets" - user decides when to move on } ?: false val hasMoreExercises = routine?.let { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/HistoryManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/HistoryManager.kt index 9254ae024..e4e2b437b 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/HistoryManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/HistoryManager.kt @@ -212,7 +212,7 @@ class HistoryManager( } } catch (e: Exception) { if (e is CancellationException) throw e // Never suppress coroutine cancellation - co.touchlab.kermit.Logger.e(e) { + Logger.e(e) { "Error loading workout history in HistoryManager init" } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/MachineSafetyCoordinator.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/MachineSafetyCoordinator.kt index 408a46306..270933576 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/MachineSafetyCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/MachineSafetyCoordinator.kt @@ -168,8 +168,7 @@ class MachineSafetyCoordinator( kind: MachineSafetyWorkoutKind, ): Boolean { val trainerAddress = transport.connectedTrainerAddress ?: return false - if (!persistMachineArming) return true - return recordMachineSessionArmed( + return !persistMachineArming || recordMachineSessionArmed( MachineSafetyHazardDocument( generation = nextGeneration + 1L, trainerAddress = trainerAddress, @@ -449,11 +448,9 @@ class MachineSafetyCoordinator( document: MachineSafetyHazardDocument, ): Boolean { val current = _uiState.value as? MachineSafetyUiState.Visible ?: return false - if (current.document.identity != identity || - document.identity != identity || - current.document.recoveryAttemptToken > document.recoveryAttemptToken - ) return false - return try { + return !(current.document.identity != identity || + document.identity != identity || + current.document.recoveryAttemptToken > document.recoveryAttemptToken) && try { repository.replace(document) show(document, current.restoreWasRejected) true diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt index 7e6194ffd..d7b5e4447 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt @@ -793,7 +793,7 @@ class RoutineFlowManager( } } // Issue #357: Log per-set weights for debugging PR% scaling - Logger.d("RoutineFlowManager") { + Logger.d(tag = "RoutineFlowManager") { "Issue #357: ${exercise.exercise.name} resolved setWeights=${resolved.setWeights} (${resolved.setWeights.size} sets), " + "setReps=${exercise.setReps} (${exercise.setReps.size} sets), baseWeight=${resolved.baseWeight}kg" } @@ -981,7 +981,7 @@ class RoutineFlowManager( } } if (hasVariableWarmups) { - Logger.d("RoutineFlowManager") { "Phase 35C: First exercise has ${firstExercise.warmupSets.size} warm-up sets" } + Logger.d(tag = "RoutineFlowManager") { "Phase 35C: First exercise has ${firstExercise.warmupSets.size} warm-up sets" } } return published } @@ -1112,8 +1112,7 @@ class RoutineFlowManager( } val resolvedRoutine = resolveRoutineWeights(routine) currentCoroutineContext().ensureActive() - if (!publicationStillCurrent()) return false - return loadRoutineInternal( + return publicationStillCurrent() && loadRoutineInternal( routine = resolvedRoutine, resumeLaunchOrigin = launchOrigin, resumeCycleId = cycleId, @@ -1177,7 +1176,7 @@ class RoutineFlowManager( coordinator._currentWarmupSetIndex.value = if (warmupExercise != null) 0 else -1 } warmupExercise?.let { exercise -> - Logger.d("RoutineFlowManager") { "Issue #356: Overview init warm-up for ${exercise.exercise.name}: ${exercise.warmupSets.size} sets" } + Logger.d(tag = "RoutineFlowManager") { "Issue #356: Overview init warm-up for ${exercise.exercise.name}: ${exercise.warmupSets.size} sets" } } } @@ -1263,13 +1262,13 @@ class RoutineFlowManager( } // Issue #357: Log weight source for PR% debugging val weightFromList = exercise.setWeightsPerCableKg.getOrNull(setIndex) - Logger.d("RoutineFlowManager") { + Logger.d(tag = "RoutineFlowManager") { "Issue #357: enterSetReady weight for set $setIndex: setWeightsPerCableKg[$setIndex]=$weightFromList, " + "fallback weightPerCableKg=${exercise.weightPerCableKg}, using=$setWeight, " + "usePercentOfPR=${exercise.usePercentOfPR}, setWeights.size=${exercise.setWeightsPerCableKg.size}" } if (hasVariableWarmups) { - Logger.d("RoutineFlowManager") { "Issue #356: SetReady init warm-up for ${exercise.exercise.name}: ${exercise.warmupSets.size} sets" } + Logger.d(tag = "RoutineFlowManager") { "Issue #356: SetReady init warm-up for ${exercise.exercise.name}: ${exercise.warmupSets.size} sets" } } clearWeightRecommendationIfNotTarget(exercise.exercise.id, setIndex) } @@ -1336,7 +1335,7 @@ class RoutineFlowManager( "enterSetReadyWithAdjustments: exercise=${exercise.exercise.name}, set=$setIndex, isAMRAP=$isSetAmrap, stallDetection=${exercise.stallDetectionEnabled}" } if (hasVariableWarmups) { - Logger.d("RoutineFlowManager") { "Issue #356: SetReadyWithAdjustments init warm-up for ${exercise.exercise.name}: ${exercise.warmupSets.size} sets" } + Logger.d(tag = "RoutineFlowManager") { "Issue #356: SetReadyWithAdjustments init warm-up for ${exercise.exercise.name}: ${exercise.warmupSets.size} sets" } } clearWeightRecommendationIfNotTarget(exercise.exercise.id, setIndex) } @@ -1497,7 +1496,7 @@ class RoutineFlowManager( fun showRoutineComplete() { // Issue #355: Idempotency - don't re-trigger navigation if already complete if (coordinator._routineFlowState.value is RoutineFlowState.Complete) { - Logger.d("RoutineFlowManager") { "showRoutineComplete: already complete, ignoring duplicate call" } + Logger.d(tag = "RoutineFlowManager") { "showRoutineComplete: already complete, ignoring duplicate call" } return } lifecycleDelegate.beginRoutineCompletedRuntimeCleanup() @@ -1591,7 +1590,7 @@ class RoutineFlowManager( // Issue #125: Block exercise navigation during Active state if (coordinator._workoutState.value is WorkoutState.Active) { - Logger.w("RoutineFlowManager") { "Cannot jump to exercise $index while workout is Active - stop workout first" } + Logger.w(tag = "RoutineFlowManager") { "Cannot jump to exercise $index while workout is Active - stop workout first" } scope.launch { coordinator._userFeedbackEvents.emit("Stop the current set first") } @@ -1602,12 +1601,12 @@ class RoutineFlowManager( val currentRepCount = coordinator._repCount.value if (currentRepCount.workingReps > 0 && coordinator._workoutState.value !is WorkoutState.Completed) { markExerciseCompleted(coordinator._currentExerciseIndex.value) - Logger.d("RoutineFlowManager") { + Logger.d(tag = "RoutineFlowManager") { "Saving progress for exercise ${coordinator._currentExerciseIndex.value}: ${currentRepCount.workingReps} reps" } } else if (coordinator._workoutState.value !is WorkoutState.Completed) { markExerciseSkipped(coordinator._currentExerciseIndex.value) - Logger.d("RoutineFlowManager") { "Skipping exercise ${coordinator._currentExerciseIndex.value}" } + Logger.d(tag = "RoutineFlowManager") { "Skipping exercise ${coordinator._currentExerciseIndex.value}" } } // Cancel any active timers @@ -1632,7 +1631,7 @@ class RoutineFlowManager( } lifecycleDelegate.resetRepCounter() enterSetReady(index, 0, reinitializeWarmups = true) - Logger.i("RoutineFlowManager") { "Jumped to exercise $index; entered Set Ready (waiting for START)" } + Logger.i(tag = "RoutineFlowManager") { "Jumped to exercise $index; entered Set Ready (waiting for START)" } } } @@ -1800,7 +1799,7 @@ class RoutineFlowManager( */ fun logRpeForCurrentSet(rpe: Int) { coordinator._currentSetRpe.value = rpe - Logger.d("RoutineFlowManager") { "RPE logged for current set: $rpe" } + Logger.d(tag = "RoutineFlowManager") { "RPE logged for current set: $rpe" } } // ===== Shared Helpers ===== diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/tv/TvSessionController.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/tv/TvSessionController.kt index 083093ad4..9ef2a97ed 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/tv/TvSessionController.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/tv/TvSessionController.kt @@ -59,9 +59,8 @@ class TvSessionController( val routineReadyToStart: Boolean get() { val ready = coordinator.routineFlowState.value as? RoutineFlowState.SetReady ?: return false - if (completionFailed || bodyweightEntry != null || machineMayBeRunning || session.machineTeardownState.value !is MachineTeardownState.Ready) return false - return coordinator.workoutState.value is WorkoutState.Idle || - lastStartedRoutineStep != (ready.exerciseIndex to ready.setIndex) + return !(completionFailed || bodyweightEntry != null || machineMayBeRunning || session.machineTeardownState.value !is MachineTeardownState.Ready) && (coordinator.workoutState.value is WorkoutState.Idle || + lastStartedRoutineStep != (ready.exerciseIndex to ready.setIndex)) } val manualRoutineRestSeconds: Int? get() { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupJsonNavigator.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupJsonNavigator.kt index df0c54935..8dca78f42 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupJsonNavigator.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupJsonNavigator.kt @@ -179,8 +179,7 @@ class BackupJsonNavigator(private val source: BackupStreamSource) { */ fun hasNextInArray(): Boolean { skipWhitespace() - if (!hasMore()) return false - return when (peekChar()) { + return hasMore() && when (peekChar()) { ']' -> false ',' -> { @@ -203,8 +202,7 @@ class BackupJsonNavigator(private val source: BackupStreamSource) { */ fun hasNextInObject(): Boolean { skipWhitespace() - if (!hasMore()) return false - return when (peekChar()) { + return hasMore() && when (peekChar()) { '}' -> false ',' -> { @@ -368,8 +366,7 @@ class BackupJsonNavigator(private val source: BackupStreamSource) { val sb = StringBuilder() while (true) { - val c = nextChar() - when (c) { + when (val c = nextChar()) { '"' -> return sb.toString() '\\' -> sb.append(readEscapeSequence()) else -> sb.append(c) @@ -384,8 +381,7 @@ class BackupJsonNavigator(private val source: BackupStreamSource) { * (including surrogate pairs for supplementary Unicode code points). */ private fun readEscapeSequence(): CharSequence { - val escaped = nextChar() - return when (escaped) { + return when (val escaped = nextChar()) { '"' -> "\"" '\\' -> "\\" '/' -> "/" @@ -445,8 +441,7 @@ class BackupJsonNavigator(private val source: BackupStreamSource) { private fun parseHex4(): Int { var result = 0 repeat(4) { i -> - val c = nextChar() - val digit = when (c) { + val digit = when (val c = nextChar()) { in '0'..'9' -> c - '0' in 'a'..'f' -> 10 + (c - 'a') in 'A'..'F' -> 10 + (c - 'A') diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt index 375371b0a..bcbac8982 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt @@ -149,7 +149,7 @@ object BlePacketFactory { // Issue #390: Detect suspiciously low weight values that would cause the machine // to start at near-zero and ramp up slowly instead of the configured weight. if (!params.isJustLift && params.weightPerCableKg > 0f && params.weightPerCableKg < 2f) { - Logger.w("BlePacket") { + Logger.w(tag = "BlePacket") { "Issue #390: weightPerCableKg=${params.weightPerCableKg}kg is suspiciously low. " + "Expected > 2kg for a working set. Check weight resolution pipeline." } @@ -170,39 +170,47 @@ object BlePacketFactory { ) // Diagnostic logging - Logger.d("BlePacket") { + Logger.d(tag = "BlePacket") { "=== MODE: ${params.programMode}, Weight: ${params.weightPerCableKg}kg ===" } - Logger.d("BlePacket") { + Logger.d(tag = "BlePacket") { "targetWeight=${targetWeightPerCable}kg, effectiveKg=$effectiveKg" } - Logger.d("BlePacket") { + Logger.d(tag = "BlePacket") { "ecc.up.minMmS[0x48]=${getInt16LE(frame, BleConstants.ActivationPacket.OFFSET_ECC_UP_MIN_MMS)}, " + "ecc.up.maxMmS[0x4A]=${getInt16LE(frame, BleConstants.ActivationPacket.OFFSET_ECC_UP_MAX_MMS)}, " + "ecc.up.ramp[0x4C]=${getFloatLE(frame, BleConstants.ActivationPacket.OFFSET_ECC_UP_RAMP)}" } - Logger.d("BlePacket") { - "forceMin[0x50]=${getFloatLE( - frame, - BleConstants.ActivationPacket.OFFSET_FORCE_MIN, - )}kg, " + - "forceMax[0x54]=${getFloatLE( + Logger.d(tag = "BlePacket") { + "forceMin[0x50]=${ + getFloatLE( frame, - BleConstants.ActivationPacket.OFFSET_FORCE_MAX, - )}kg" + BleConstants.ActivationPacket.OFFSET_FORCE_MIN, + ) + }kg, " + + "forceMax[0x54]=${ + getFloatLE( + frame, + BleConstants.ActivationPacket.OFFSET_FORCE_MAX, + ) + }kg" } - Logger.d("BlePacket") { - "targetWeight[0x58]=${getFloatLE( - frame, - BleConstants.ActivationPacket.OFFSET_TARGET_WEIGHT, - )}kg, " + - "progression[0x5C]=${getFloatLE( + Logger.d(tag = "BlePacket") { + "targetWeight[0x58]=${ + getFloatLE( frame, - BleConstants.ActivationPacket.OFFSET_PROGRESSION, - )}kg/rep" + BleConstants.ActivationPacket.OFFSET_TARGET_WEIGHT, + ) + }kg, " + + "progression[0x5C]=${ + getFloatLE( + frame, + BleConstants.ActivationPacket.OFFSET_PROGRESSION, + ) + }kg/rep" } val repsHex = frame[0x04].toUByte().toString(16).padStart(2, '0').uppercase() - Logger.d("BlePacket") { + Logger.d(tag = "BlePacket") { "reps[0x04]=0x$repsHex (isAMRAP=${params.isAMRAP}, isJustLift=${params.isJustLift})" } @@ -251,7 +259,7 @@ object BlePacketFactory { val echoParams = getEchoParams(level, eccentricPct) - Logger.d("BlePacketFactory") { + Logger.d(tag = "BlePacketFactory") { "=== ECHO: ${level.displayName}, eccentric: $eccentricPct% ===" } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt index c80be6205..db923acdd 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt @@ -1240,8 +1240,7 @@ abstract class BaseDataBackupManager( "data" -> { nav.beginObject() while (nav.hasNextInObject()) { - val fieldName = nav.nextName() - when (fieldName) { + when (val fieldName = nav.nextName()) { // --- equipmentRackItems --- "equipmentRackItems" -> { legacyRackFieldPresent = true diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/ApplePeripheral.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/ApplePeripheral.kt index d600dc6d8..0955ea0d4 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/ApplePeripheral.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/ApplePeripheral.kt @@ -8,4 +8,4 @@ package com.juul.kable ), level = DeprecationLevel.HIDDEN, ) -public typealias ApplePeripheral = CoreBluetoothPeripheral +typealias ApplePeripheral = CoreBluetoothPeripheral diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/Bluetooth.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/Bluetooth.kt index a640ff11e..8f4e55770 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/Bluetooth.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/Bluetooth.kt @@ -23,7 +23,7 @@ import platform.CoreBluetooth.CBCentralManagerStateUnsupported "Will be removed in a future release. " + "See https://github.com/JuulLabs/kable/issues/737 for more details.", ) -public actual enum class Reason { +actual enum class Reason { @Deprecated( message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + "Will be removed in a future release. " + diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/CentralManager.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/CentralManager.kt index 841032605..6ab598e37 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/CentralManager.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/CentralManager.kt @@ -15,31 +15,31 @@ import kotlin.uuid.Uuid private const val DISPATCH_QUEUE_LABEL = "central" -public class CentralManager internal constructor( +class CentralManager internal constructor( options: Map?, ) { - public data class Configuration( + data class Configuration( val stateRestoration: Boolean, ) { - public class Builder internal constructor() { + class Builder internal constructor() { /** * Enables support for * [Core Bluetooth Background Processing for iOS Apps](https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html) * by enabling [CentralManager] state preservation and restoration. */ - public var stateRestoration: Boolean = false + var stateRestoration: Boolean = false } } - public companion object { + companion object { private val configuration = atomic(null) private val lazyDefault = lazy { CentralManager(configuration.value?.toOptions()) } internal val Default by lazyDefault - public fun configure(builderAction: Configuration.Builder.() -> Unit) { + fun configure(builderAction: Configuration.Builder.() -> Unit) { check(!lazyDefault.isInitialized()) { "Cannot configure CentralManager, it has already initialized." } configuration.value = Configuration.Builder().apply(builderAction).build() } diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothAdvertisement.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothAdvertisement.kt index b19fa8b18..fa3c57ecd 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothAdvertisement.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothAdvertisement.kt @@ -5,4 +5,4 @@ package com.juul.kable replaceWith = ReplaceWith("PlatformAdvertisement"), level = DeprecationLevel.ERROR, ) -public typealias CoreBluetoothAdvertisement = PlatformAdvertisement +typealias CoreBluetoothAdvertisement = PlatformAdvertisement diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothPeripheral.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothPeripheral.kt index 8ce8f3c2f..ece000026 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothPeripheral.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothPeripheral.kt @@ -5,22 +5,22 @@ import kotlinx.io.IOException import platform.Foundation.NSData import kotlin.coroutines.cancellation.CancellationException -public interface CoreBluetoothPeripheral : Peripheral { +interface CoreBluetoothPeripheral : Peripheral { @Throws(CancellationException::class, IOException::class) - public suspend fun write(descriptor: Descriptor, data: NSData) + suspend fun write(descriptor: Descriptor, data: NSData) @Throws(CancellationException::class, IOException::class) - public suspend fun write(characteristic: Characteristic, data: NSData, writeType: WriteType) + suspend fun write(characteristic: Characteristic, data: NSData, writeType: WriteType) @Throws(CancellationException::class, IOException::class) - public suspend fun readAsNSData(descriptor: Descriptor): NSData + suspend fun readAsNSData(descriptor: Descriptor): NSData - public fun observeAsNSData( + fun observeAsNSData( characteristic: Characteristic, onSubscription: OnSubscriptionAction = {}, ): Flow @Throws(CancellationException::class, IOException::class) - public suspend fun readAsNSData(characteristic: Characteristic): NSData + suspend fun readAsNSData(characteristic: Characteristic): NSData } diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothScanner.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothScanner.kt index 43693cfdf..6e058e81c 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothScanner.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/CoreBluetoothScanner.kt @@ -5,4 +5,4 @@ package com.juul.kable replaceWith = ReplaceWith("PlatformScanner"), level = DeprecationLevel.HIDDEN, ) -public typealias CoreBluetoothScanner = PlatformScanner +typealias CoreBluetoothScanner = PlatformScanner diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/Identifier.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/Identifier.kt index ec19f35af..75d61f467 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/Identifier.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/Identifier.kt @@ -2,6 +2,6 @@ package com.juul.kable import kotlin.uuid.Uuid -public actual typealias Identifier = Uuid +actual typealias Identifier = Uuid -public actual fun String.toIdentifier(): Identifier = Uuid.parse(this) +actual fun String.toIdentifier(): Identifier = Uuid.parse(this) diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/Peripheral.deprecated.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/Peripheral.deprecated.kt index 5bb063d37..4c31126c8 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/Peripheral.deprecated.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/Peripheral.deprecated.kt @@ -8,7 +8,7 @@ import platform.CoreBluetooth.CBPeripheral replaceWith = ReplaceWith("Peripheral(cbPeripheral, builderAction)"), level = DeprecationLevel.ERROR, ) -public actual fun CoroutineScope.peripheral( +actual fun CoroutineScope.peripheral( advertisement: Advertisement, builderAction: PeripheralBuilderAction, ): Peripheral = Peripheral(advertisement, builderAction) @@ -18,7 +18,7 @@ public actual fun CoroutineScope.peripheral( replaceWith = ReplaceWith("Peripheral(cbPeripheral, builderAction)"), level = DeprecationLevel.ERROR, ) -public fun CoroutineScope.peripheral( +fun CoroutineScope.peripheral( identifier: Identifier, builderAction: PeripheralBuilderAction = {}, ): Peripheral = Peripheral(identifier, builderAction) @@ -28,7 +28,7 @@ public fun CoroutineScope.peripheral( replaceWith = ReplaceWith("Peripheral(cbPeripheral, builderAction)"), level = DeprecationLevel.ERROR, ) -public fun CoroutineScope.peripheral( +fun CoroutineScope.peripheral( cbPeripheral: CBPeripheral, builderAction: PeripheralBuilderAction, ): CoreBluetoothPeripheral = Peripheral(cbPeripheral, builderAction) diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/Peripheral.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/Peripheral.kt index 7367aef43..2d1e6c969 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/Peripheral.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/Peripheral.kt @@ -2,7 +2,7 @@ package com.juul.kable import platform.CoreBluetooth.CBPeripheral -public actual fun Peripheral( +actual fun Peripheral( advertisement: Advertisement, builderAction: PeripheralBuilderAction, ): Peripheral { @@ -11,7 +11,7 @@ public actual fun Peripheral( } @Suppress("FunctionName") // Builder function. -public fun Peripheral( +fun Peripheral( identifier: Identifier, builderAction: PeripheralBuilderAction = {}, ): CoreBluetoothPeripheral { @@ -21,7 +21,7 @@ public fun Peripheral( } @Suppress("FunctionName") // Builder function. -public fun Peripheral( +fun Peripheral( cbPeripheral: CBPeripheral, builderAction: PeripheralBuilderAction = {}, ): CoreBluetoothPeripheral { diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/PeripheralBuilder.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/PeripheralBuilder.kt index a576b5b04..9e05942ba 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/PeripheralBuilder.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/PeripheralBuilder.kt @@ -6,22 +6,22 @@ import kotlinx.io.IOException import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration -public actual class ServicesDiscoveredPeripheral internal constructor( +actual class ServicesDiscoveredPeripheral internal constructor( private val peripheral: CoreBluetoothPeripheral, ) { @Throws(CancellationException::class, IOException::class, NotConnectedException::class) - public actual suspend fun read( + actual suspend fun read( characteristic: Characteristic, ): ByteArray = peripheral.read(characteristic) @Throws(CancellationException::class, IOException::class, NotConnectedException::class) - public actual suspend fun read( + actual suspend fun read( descriptor: Descriptor, ): ByteArray = peripheral.read(descriptor) @Throws(CancellationException::class, IOException::class, NotConnectedException::class) - public actual suspend fun write( + actual suspend fun write( characteristic: Characteristic, data: ByteArray, writeType: WriteType, @@ -30,7 +30,7 @@ public actual class ServicesDiscoveredPeripheral internal constructor( } @Throws(CancellationException::class, IOException::class, NotConnectedException::class) - public actual suspend fun write( + actual suspend fun write( descriptor: Descriptor, data: ByteArray, ) { @@ -38,26 +38,26 @@ public actual class ServicesDiscoveredPeripheral internal constructor( } } -public actual class PeripheralBuilder internal actual constructor() { +actual class PeripheralBuilder internal actual constructor() { internal var logging: Logging = Logging() - public actual fun logging(init: LoggingBuilder) { + actual fun logging(init: LoggingBuilder) { val logging = Logging() logging.init() this.logging = logging } internal var onServicesDiscovered: ServicesDiscoveredAction = {} - public actual fun onServicesDiscovered(action: ServicesDiscoveredAction) { + actual fun onServicesDiscovered(action: ServicesDiscoveredAction) { onServicesDiscovered = action } internal var observationExceptionHandler: ObservationExceptionHandler = { cause -> throw cause } - public actual fun observationExceptionHandler(handler: ObservationExceptionHandler) { + actual fun observationExceptionHandler(handler: ObservationExceptionHandler) { observationExceptionHandler = handler } - public actual var disconnectTimeout: Duration = defaultDisconnectTimeout + actual var disconnectTimeout: Duration = defaultDisconnectTimeout - public actual var forceCharacteristicEqualityByUuid: Boolean = false + actual var forceCharacteristicEqualityByUuid: Boolean = false } diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/PlatformAdvertisement.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/PlatformAdvertisement.kt index a43c9a7fc..0803da182 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/PlatformAdvertisement.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/PlatformAdvertisement.kt @@ -3,8 +3,8 @@ package com.juul.kable import platform.Foundation.NSData import kotlin.uuid.Uuid -public actual interface PlatformAdvertisement : Advertisement { - public fun serviceDataAsNSData(uuid: Uuid): NSData? - public val manufacturerDataAsNSData: NSData? - public fun manufacturerDataAsNSData(companyIdentifierCode: Int): NSData? +actual interface PlatformAdvertisement : Advertisement { + fun serviceDataAsNSData(uuid: Uuid): NSData? + val manufacturerDataAsNSData: NSData? + fun manufacturerDataAsNSData(companyIdentifierCode: Int): NSData? } diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/ScannerBuilder.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/ScannerBuilder.kt index 29ce89684..85dd46514 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/ScannerBuilder.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/ScannerBuilder.kt @@ -6,18 +6,18 @@ import platform.CoreBluetooth.CBCentralManagerScanOptionAllowDuplicatesKey import platform.CoreBluetooth.CBCentralManagerScanOptionSolicitedServiceUUIDsKey import kotlin.uuid.Uuid -public actual class ScannerBuilder { +actual class ScannerBuilder { @Deprecated( message = "Use filters(FiltersBuilder.() -> Unit)", replaceWith = ReplaceWith("filters { }"), level = DeprecationLevel.HIDDEN, ) - public actual var filters: List? = null + actual var filters: List? = null private var filterPredicates: List = emptyList() - public actual fun filters(builderAction: FiltersBuilder.() -> Unit) { + actual fun filters(builderAction: FiltersBuilder.() -> Unit) { filterPredicates = FiltersBuilder().apply(builderAction).build() } @@ -25,18 +25,18 @@ public actual class ScannerBuilder { * Specifies whether the scan should run without duplicate filtering. This corresponds to * Core Bluetooth's [CBCentralManagerScanOptionAllowDuplicatesKey] scanning option. */ - public var allowDuplicateKeys: Boolean? = true + var allowDuplicateKeys: Boolean? = true /** * Causes the scanner to scan for peripherals soliciting any of the services contained in the * array. This corresponds to Core Bluetooth's [CBCentralManagerScanOptionSolicitedServiceUUIDsKey] * scanning option. */ - public var solicitedServiceUuids: List? = null + var solicitedServiceUuids: List? = null private var logging: Logging = Logging() - public actual fun logging(init: LoggingBuilder) { + actual fun logging(init: LoggingBuilder) { logging = Logging().apply(init) } diff --git a/third_party/kable-core-tvos/src/appleMain/kotlin/logs/SystemLogEngine.kt b/third_party/kable-core-tvos/src/appleMain/kotlin/logs/SystemLogEngine.kt index 2471c27af..af84523a6 100644 --- a/third_party/kable-core-tvos/src/appleMain/kotlin/logs/SystemLogEngine.kt +++ b/third_party/kable-core-tvos/src/appleMain/kotlin/logs/SystemLogEngine.kt @@ -2,7 +2,7 @@ package com.juul.kable.logs import platform.Foundation.NSLog -public actual object SystemLogEngine : LogEngine { +actual object SystemLogEngine : LogEngine { actual override fun verbose(throwable: Throwable?, tag: String, message: String) { log("V", tag, message, throwable) } From 18b4330545b7887f57e1285f31c905a9680c39bd Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 29 Oct 2026 18:55:11 -0400 Subject: [PATCH 2/3] `diagnostics(audio): instrument iOS workout audio and add rest timer tests` - Introduce `IosAudioDiagnostics` helper and `WORKOUT_AUDIO` log event to record non-intrusive iOS audio session details, route changes, interruptions, and cue playback results (#1357). - Instrument `ActiveSessionEngine`, `HapticFeedbackEffect`, and `IosSafeWordListener` to track rep audio events, session lifecycle transitions, and native player execution. - Add unit tests in `commonTest` and UI tests in `androidHostTest` covering rest timer extension, pause, and resume behavior (#1360). - Add QA documentation detailing investigation procedures for iOS audio diagnostics and rest timer controls. --- docs/qa/issue-1357-ios-audio.md | 75 ++++++++ docs/qa/issue-1360-rest-controls.md | 90 +++++++++ .../screen/Issue1360RestTimerUiTest.kt | 121 ++++++++++++ .../repository/ConnectionLogRepository.kt | 1 + .../manager/ActiveSessionEngine.kt | 19 ++ .../manager/Issue1360RestTimerControlsTest.kt | 172 ++++++++++++++++++ .../domain/voice/IosSafeWordListener.kt | 72 ++++++-- .../components/HapticFeedbackEffect.ios.kt | 122 +++++++++++-- .../util/IosAudioDiagnostics.kt | 60 ++++++ 9 files changed, 698 insertions(+), 34 deletions(-) create mode 100644 docs/qa/issue-1357-ios-audio.md create mode 100644 docs/qa/issue-1360-rest-controls.md create mode 100644 shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/screen/Issue1360RestTimerUiTest.kt create mode 100644 shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/Issue1360RestTimerControlsTest.kt create mode 100644 shared/src/iosMain/kotlin/com/devil/phoenixproject/util/IosAudioDiagnostics.kt diff --git a/docs/qa/issue-1357-ios-audio.md b/docs/qa/issue-1357-ios-audio.md new file mode 100644 index 000000000..17afa679a --- /dev/null +++ b/docs/qa/issue-1357-ios-audio.md @@ -0,0 +1,75 @@ +# Issue #1357: capture iOS workout audio loss + +Status: diagnostic instrumentation; root cause and recovery fix remain unconfirmed. +Tracking: [#1357](https://github.com/9thLevelSoftware/Project-Phoenix-MP/issues/1357). + +The report identifies an iPhone 13 Pro despite the issue template's Android selection. +Sounds work during the three-rep workout setup, then remain silent after switching +to Email and returning without closing Phoenix. Other reported triggers are an +incoming call and starting with the phone's sound off before turning it on. Record +which sound control was used; silent mode, media volume, and output route are +different variables. + +The earlier simulator Test Sounds/Safari run reported successful native player +calls. It did not reproduce this workout failure, exercise an actual interruption, +or establish audible output. Existing foreground/interruption-ended recovery is +already present. Missing native results were a diagnostic gap, not an established +cause. This patch adds observations without changing playback/recovery policy. + +## Capture on an affected iPhone + +1. Build/install the instrumented revision on macOS first. Record revision, app + version, iPhone model, actual iOS version, sound settings, output device type, + and whether Safe Word is enabled. Do not change these between the baseline + and failing sequence. +2. Open **Settings → Connection Logs**, ensure logging is enabled, and clear old + entries before starting. The log is in memory, capped at 1,000 entries shared + with BLE diagnostics, and is lost on app termination. +3. Start the workout and verify an audible cue during the three-rep setup. Note + the time. Switch to Email, return to Phoenix, then trigger a few expected cues. + Note what was heard, whether rep counts continued, and whether haptics worked. +4. Immediately open **Connection Logs → Export → Copy to clipboard**. Preserve + the full export before more workout/BLE events evict the relevant entries. + The `WORKOUT_AUDIO` search helps viewing; export includes all retained entries. +5. In separate runs, capture the incoming-call and sound-off/start/on sequences. + Record call dismissal versus answering, and the exact sound control used. + A normal app switch and an audio interruption are separate test cases. + +Do not restart Phoenix before exporting the failure. The new audio records omit +route names/UIDs, speech, configured safe words, and error descriptions/userInfo; +the full existing connection export can still contain device identifiers. + +## Reading the evidence + +Order `WORKOUT_AUDIO` records by timestamp (exports are newest first). Correlate: + +- `ActiveSessionEngine` rep-event snapshots: rep type/count, cue preference flags, + timing, and subscriber count. These occur after execution-lease validation and + before cue selection; they do not prove an emission or acoustic output. +- The iOS collector lifetime and received cue, then selected-player outcomes. +- App lifecycle, interruption type/options/suspension information, route changes, + and media-service notifications. +- Audio-session configuration/activation results and NSError domain/code, with + session category/mode/options, output volume, and output port types. +- Safe Word configuration/deactivation and recovery ordering, when enabled. + +A missing collector receipt narrows the investigation to cue selection/delivery +or collector lifetime. A received cue with failed activation/preparation/playback +narrows it to native audio. Successful native calls with silence still require +route, volume, and audible observations; `isPlaying=true` is not acoustic proof. +No single record establishes Safe Word ownership: correlate its own transitions +instead of inferring ownership from `PlayAndRecord` alone. + +Apple documents [audio interruptions](https://developer.apple.com/documentation/avfaudio/handling-audio-interruptions), +[route changes](https://developer.apple.com/documentation/avfaudio/responding-to-audio-route-changes), +and [media-service resets](https://developer.apple.com/documentation/avfaudio/avaudiosession/mediaserviceswereresetnotification) +as separate notification paths. The added route/reset observers only record them. + +## Validation boundary + +Host tests can check shared behavior and existing iOS source guards. They cannot +compile Apple platform interop or establish notification delivery, session state, +or audible recovery. A macOS iOS framework/app build and the failing physical-device +trace remain required before selecting a root-cause fix. After that fix, repeat +the failing sequence with audible cues before/after, including Safe Word on/off +and speaker/headphone routes where relevant. diff --git a/docs/qa/issue-1360-rest-controls.md b/docs/qa/issue-1360-rest-controls.md new file mode 100644 index 000000000..0fc8926b3 --- /dev/null +++ b/docs/qa/issue-1360-rest-controls.md @@ -0,0 +1,90 @@ +# Issue #1360: rest controls investigation + +Status: **not reproduced locally; root cause and iPhone fix remain unverified**. + +## Reported build + +- [Issue #1360](https://github.com/9thLevelSoftware/Project-Phoenix-MP/issues/1360) contains separate reports for `+30s` and Pause. Verify them independently. +- The supplied App Store Connect screenshot identifies both submissions as **1.0.7 (2026102094), iPhone 16 Pro, iOS 27.0.1**. +- Local tags `testflight/2026102094` and `v1.0.7` resolve to `d22f4e06`. +- Investigation checkout: `fa36e449` on `update/version-and-dependencies`, with concurrent, unrelated issue #1357 audio changes preserved. + +## Findings + +The released UI wires both controls through `RestTimerCard`, `WorkoutTab`, the +`workoutActions` adapter, `ActiveWorkoutScreen`, `MainViewModel`, and +`DefaultWorkoutSessionManager` into `ActiveSessionEngine`. No missing callback +or static overlapping pointer handler was found. The rest card relies on +`WorkoutTab` for vertical scrolling. + +The live persisted-rest controls require the same execution/profile/plan +authority as the running countdown. The normal completion path retains its +execution lease during rest. Rest mutations persist the new runtime document +before publishing the updated countdown or pause state. A rejected authority +check or failed repository write can therefore leave the display unchanged; +there is no device evidence identifying either as the cause of this report. + +The relevant button wiring, timer mutation logic, runtime repository, and rest +deadline calculator have no behavioral repair between the reported tag and +the investigation checkout. The later phone source-set move and Polar strip +do not establish a cause for the older build. No corresponding timer feature +was found in the sibling portal's source, Edge Functions, or migrations. + +## Local verification, 2026-10-29 + +Added behavioral coverage: + +- `Issue1360RestTimerControlsTest`: four tests use the real session manager and + timer engine with repository/BLE fakes. They cover a TUT rest before the next + set and before the next exercise. `+30s` persists and survives subsequent + ticks; Pause holds the countdown and Resume restarts it. Controls do not + advance navigation or start another set. +- `Issue1360RestTimerUiTest`: two Robolectric Compose tests send actual touch + input through the rest card and production action adapter in a short, + scrolling viewport. They verify displayed extensions, Pause/Resume, and + extension while paused. The observable UI state sink is deliberately + separate from the engine fixture; this is not a full iOS app test. + +The first targeted attempt did not execute assertions: another Gradle build +replaced the shared test output and the runner could not load the new class. +The overlapping full run subsequently compiled and executed both new classes: + +```powershell +.\gradlew.bat '-Pskip.supabase.check=true' :shared:testAndroidHostTest :androidApp:testDebugUnitTest --continue +``` + +The completed run's log and JUnit XML were inspected: **BUILD SUCCESSFUL**; +shared suite **5,447 tests, 9 skipped, 0 failures/errors**; Android app suite +**26 tests, 0 skipped, 0 failures/errors**. All six new tests ran and passed. +Successful checks were not repeated. Existing compiler/deprecation warnings +remain. This run includes the concurrent audio work; it is not isolated proof +of that work or of native iOS behavior. + +No production fix was made for #1360. Passing host tests do not invalidate the +TestFlight reports or establish that the reported build works on an iPhone. + +## Remaining device evidence + +On the reported build, or a clearly identified newer TestFlight build: + +1. Use a TUT routine with at least two sets and a following exercise. Record + whether the app stayed foregrounded and whether the workout was resumed. +2. During a running rest, tap `+30s` once. Record the displayed value before + the tap, immediately afterward, and after several countdown ticks. +3. In a separate rest, tap Pause, wait at least five seconds, then Resume. + Record the button label, paused indicator, and displayed time at each step. +4. Repeat before a transition to another exercise. Record whether scrolling, + Reset, and Skip Rest respond; those observations help separate touch + delivery from timer mutation rejection. +5. Preserve a screen recording and exported Connection Logs with the exact + app build. Existing logs do not expose every persisted-rest early return, + so an absent log entry alone cannot prove a tap was lost. + +If the failure reproduces, the next diagnostic patch should record a +correlated UI receipt, engine receipt, persistence attempt, and terminal +publication/rejection for each control. Include only action, timer path, +remaining seconds, pause state, and authority booleans; omit user identifiers, +exercise names, serialized documents, and raw exception messages. Avoid +per-tick logging. Preserve persistence, cancellation, and ownership behavior. +Connection logging defaults to enabled and has a bounded buffer; an explicitly +opt-in trace would need its own default-off control. diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/screen/Issue1360RestTimerUiTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/screen/Issue1360RestTimerUiTest.kt new file mode 100644 index 000000000..4ac9cc7b7 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/screen/Issue1360RestTimerUiTest.kt @@ -0,0 +1,121 @@ +package com.devil.phoenixproject.presentation.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Issue #1360: exercise touch dispatch through the real rest card and action factory. + * State is deliberately a small observable sink: engine countdown behavior is tested + * separately. Robolectric cannot establish native iOS gesture behavior. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w402dp-h874dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class Issue1360RestTimerUiTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun addThirtySeconds_updatesDisplayedTimeThroughWorkoutActions() { + renderRestTimer() + + assertDisplayed("1:00") + tap("30s") + assertDisplayed("1:30") + tap("30s") + assertDisplayed("2:00") + } + + @Test + fun pauseAndResume_updatesDisplayedStateAndStillAllowsExtension() { + renderRestTimer() + + tap("Pause") + assertDisplayed("PAUSED") + assertDisplayed("Resume") + assertDisplayed("1:00") + + tap("30s") + assertDisplayed("1:30") + assertDisplayed("PAUSED") + + tap("Resume") + assertDisplayed("Pause") + rule.onNodeWithText("PAUSED").assertDoesNotExist() + assertDisplayed("1:30") + } + + private fun renderRestTimer() { + val seconds = mutableStateOf(60) + val paused = mutableStateOf(false) + val actions = workoutActions( + onScan = {}, + onStopWorkout = {}, + onSkipRest = {}, + onExtendRest = { seconds.value += it }, + onToggleRestPause = { paused.value = !paused.value }, + onSkipCountdown = {}, + onProceedFromSummary = {}, + onRpeLogged = {}, + onResetForNewWorkout = {}, + onStartNextExercise = {}, + onJumpToExercise = {}, + onUpdateParameters = {}, + kgToDisplay = { value, _ -> value }, + displayToKg = { value, _ -> value }, + formatWeight = { value, _ -> "$value kg" }, + ) + rule.setContent { + MaterialTheme { + // Match WorkoutTab's scrolling host, with a deliberately short viewport. + Column( + Modifier.fillMaxWidth().height(400.dp) + .verticalScroll(rememberScrollState()), + ) { + RestTimerCard( + restSecondsRemaining = seconds.value, + nextExerciseName = "Squat", + isLastExercise = false, + currentSet = 1, + totalSets = 3, + isRestPaused = paused.value, + formatWeightWithUnit = actions::formatWeight, + onSkipRest = { actions.onSkipRest() }, + onExtendRest = actions::onExtendRest, + onToggleRestPause = actions::onToggleRestPause, + onResetRest = actions::onResetRest, + onEndWorkout = actions::onStopWorkout, + ) + } + } + } + } + + private fun tap(text: String) { + rule.onNodeWithText(text).performScrollTo().assertIsDisplayed() + .performTouchInput { click() } + } + + private fun assertDisplayed(text: String) { + rule.onNodeWithText(text).performScrollTo().assertIsDisplayed() + } +} diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ConnectionLogRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ConnectionLogRepository.kt index 0d0d451b2..a63e903f4 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ConnectionLogRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/ConnectionLogRepository.kt @@ -46,6 +46,7 @@ object LogEventType { const val WORKOUT_EXECUTION = "WORKOUT_EXECUTION" const val WORKOUT_TEARDOWN = "WORKOUT_TEARDOWN" const val WORKOUT_REP_REJECTED = "WORKOUT_REP_REJECTED" + const val WORKOUT_AUDIO = "WORKOUT_AUDIO" const val WORKOUT_PERSISTENCE = "WORKOUT_PERSISTENCE" const val MACHINE_SAFETY = "MACHINE_SAFETY" } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 26b9c5fda..e4e115f33 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -5119,6 +5119,25 @@ class ActiveSessionEngine( if (!executionGuard.isCurrent(eventLease)) return@launch val timing = coordinator._workoutParameters.value.repCountTiming val params = coordinator._workoutParameters.value + // Issue #1357: correlate workout-side decisions with the iOS collector/native + // audio trace. This is a snapshot, not proof that a cue was emitted or heard. + try { + val audioLogs = ConnectionLogRepository.instance + if (audioLogs.isEnabled.value) { + val prefs = settingsManager.userPreferences.value + audioLogs.debug( + LogEventType.WORKOUT_AUDIO, + "rep-event", + details = "type=${event.type} warmup=${event.warmupCount} working=${event.workingCount} " + + "timing=$timing repSound=${prefs.repSoundEnabled} " + + "repVoice=${prefs.audioRepCountEnabled} " + + "subscribers=${coordinator._hapticEvents.subscriptionCount.value}", + tag = "ActiveSessionEngine", + ) + } + } catch (_: Exception) { + // Diagnostic collection must not interrupt workout processing. + } when (event.type) { RepType.WORKING_PENDING -> { // TOP timing: announce rep number at concentric peak diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/Issue1360RestTimerControlsTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/Issue1360RestTimerControlsTest.kt new file mode 100644 index 000000000..3d69fae8c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/Issue1360RestTimerControlsTest.kt @@ -0,0 +1,172 @@ +package com.devil.phoenixproject.presentation.manager + +import com.devil.phoenixproject.data.repository.ActiveWorkoutRuntimeDocument +import com.devil.phoenixproject.domain.model.ProgramMode +import com.devil.phoenixproject.domain.model.SetEndReason +import com.devil.phoenixproject.domain.model.WorkoutState +import com.devil.phoenixproject.testutil.DWSMTestHarness +import com.devil.phoenixproject.testutil.WorkoutStateFixtures.createTestRoutine +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class Issue1360RestTimerControlsTest { + @Test + fun extendTutRestBeforeNextSetSurvivesTimerTicks() = runTest { + verifyExtension(nextExercise = false) + } + + @Test + fun extendTutRestBeforeNextExerciseSurvivesTimerTicks() = runTest { + verifyExtension(nextExercise = true) + } + + @Test + fun pauseAndResumeTutRestBeforeNextSetControlsCountdown() = runTest { + verifyPauseAndResume(nextExercise = false) + } + + @Test + fun pauseAndResumeTutRestBeforeNextExerciseControlsCountdown() = runTest { + verifyPauseAndResume(nextExercise = true) + } + + private suspend fun TestScope.verifyExtension(nextExercise: Boolean) { + val harness = DWSMTestHarness(this) + try { + prepareTutRest(harness, nextExercise) + val before = runtime(harness) + val remaining = resting(harness).restSecondsRemaining + val starts = harness.fakeBleRepo.programCommands.size + + harness.dwsm.extendRestTime(30) + runCurrent() + + val extended = runtime(harness) + assertEquals(before.originalRestDurationSeconds + 30, extended.originalRestDurationSeconds) + assertEquals(remaining + 30, resting(harness).restSecondsRemaining) + assertEquals(extended.originalRestDurationSeconds, harness.coordinator.restOriginalDuration.value) + assertEquals(extended, committedRuntime(harness, extended)) + assertEquals( + extended.originalRestDurationSeconds, + assertIs(extended.restTransitionPlan).restDurationSeconds, + ) + + advanceTimeBy(5_000) + runCurrent() + + assertEquals(remaining + 25, resting(harness).restSecondsRemaining) + assertEquals(remaining + 25, harness.coordinator._restSecondsRemaining.value) + assertEquals(extended, committedRuntime(harness, extended)) + assertEquals(starts, harness.fakeBleRepo.programCommands.size) + assertEquals(0, harness.coordinator.currentExerciseIndex.value) + assertEquals(0, harness.coordinator.currentSetIndex.value) + } finally { + harness.cleanup() + } + } + + private suspend fun TestScope.verifyPauseAndResume(nextExercise: Boolean) { + val harness = DWSMTestHarness(this) + try { + prepareTutRest(harness, nextExercise) + val remaining = resting(harness).restSecondsRemaining + val starts = harness.fakeBleRepo.programCommands.size + + harness.dwsm.toggleRestPause() + runCurrent() + + val paused = runtime(harness) + assertTrue(paused.isRestPaused) + assertTrue(harness.coordinator.isRestPaused.value) + assertEquals(remaining, paused.pausedRestRemainingSeconds) + assertEquals(null, paused.restDeadlineEpochMs) + assertEquals(paused, committedRuntime(harness, paused)) + + advanceTimeBy(10_000) + runCurrent() + + assertEquals(remaining, resting(harness).restSecondsRemaining) + assertEquals(remaining, harness.coordinator._restSecondsRemaining.value) + + harness.dwsm.toggleRestPause() + runCurrent() + + val resumed = runtime(harness) + assertFalse(resumed.isRestPaused) + assertFalse(harness.coordinator.isRestPaused.value) + assertEquals(null, resumed.pausedRestRemainingSeconds) + assertEquals(harness.nowMs + remaining * 1_000L, resumed.restDeadlineEpochMs) + assertEquals(resumed, committedRuntime(harness, resumed)) + + advanceTimeBy(5_000) + runCurrent() + + assertEquals(remaining - 5, resting(harness).restSecondsRemaining) + assertEquals(remaining - 5, harness.coordinator._restSecondsRemaining.value) + assertEquals(starts, harness.fakeBleRepo.programCommands.size) + assertEquals(0, harness.coordinator.currentExerciseIndex.value) + assertEquals(0, harness.coordinator.currentSetIndex.value) + } finally { + harness.cleanup() + } + } + + private suspend fun TestScope.prepareTutRest(harness: DWSMTestHarness, nextExercise: Boolean) { + harness.fakeBleRepo.simulateConnect("Vee_Test") + harness.setActiveSummaryCountdownSeconds(-1) + val source = createTestRoutine( + exerciseCount = if (nextExercise) 2 else 1, + setsPerExercise = if (nextExercise) 1 else 2, + ) + val routine = source.copy( + exercises = source.exercises.map { exercise -> + exercise.copy( + programMode = ProgramMode.TUT, + setRestSeconds = List(exercise.setReps.size) { 60 }, + ) + }, + ) + routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } + harness.dwsm.loadRoutine(routine) + advanceUntilIdle() + harness.dwsm.enterSetReady(0, 0) + harness.dwsm.startWorkout(skipCountdown = true) + advanceUntilIdle() + assertEquals(WorkoutState.Active, harness.coordinator.workoutState.value) + assertEquals(ProgramMode.TUT, harness.coordinator.workoutParameters.value.programMode) + + val lease = harness.activeSessionEngine.currentExecutionLeaseForTest() + harness.activeSessionEngine.handleSetCompletion(lease, SetEndReason.TARGET_REPS_REACHED) + advanceTimeBy(1_000) + runCurrent() + + val rest = resting(harness) + assertIs(harness.restTransitionPlan.value) + assertEquals(routine.exercises[if (nextExercise) 1 else 0].exercise.displayName, rest.nextExerciseName) + assertTrue(rest.restSecondsRemaining > 0) + assertTrue(harness.coordinator.restTimerJob?.isActive == true) + } + + private fun resting(harness: DWSMTestHarness): WorkoutState.Resting = assertIs(harness.coordinator.workoutState.value) + + private fun runtime(harness: DWSMTestHarness): ActiveWorkoutRuntimeDocument = assertNotNull(harness.activeSessionEngine.activeRuntimeDocumentForTest()) + + private fun committedRuntime( + harness: DWSMTestHarness, + document: ActiveWorkoutRuntimeDocument, + ): ActiveWorkoutRuntimeDocument? = harness.fakeActiveWorkoutRuntimeRepository.committedDocument( + document.profileId, + document.routineSessionId, + ) +} diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/voice/IosSafeWordListener.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/voice/IosSafeWordListener.kt index ad2dbcbd3..ec3667df1 100644 --- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/voice/IosSafeWordListener.kt +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/domain/voice/IosSafeWordListener.kt @@ -1,7 +1,8 @@ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) package com.devil.phoenixproject.domain.voice +import com.devil.phoenixproject.util.IosAudioDiagnostics import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ObjCObjectVar import kotlinx.cinterop.alloc @@ -21,8 +22,10 @@ import platform.AVFAudio.AVAudioSessionCategoryOptionDefaultToSpeaker import platform.AVFAudio.AVAudioSessionCategoryOptionMixWithOthers import platform.AVFAudio.AVAudioSessionCategoryPlayAndRecord import platform.AVFAudio.AVAudioSessionInterruptionNotification +import platform.AVFAudio.AVAudioSessionInterruptionOptionKey import platform.AVFAudio.AVAudioSessionInterruptionTypeEnded import platform.AVFAudio.AVAudioSessionInterruptionTypeKey +import platform.AVFAudio.AVAudioSessionInterruptionWasSuspendedKey import platform.AVFAudio.AVAudioSessionModeDefault import platform.AVFAudio.AVAudioSessionRecordPermissionDenied import platform.AVFAudio.AVAudioSessionRecordPermissionGranted @@ -111,6 +114,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe private var lifecycleObserverGeneration = 0L override fun startListening() { + audioDiagnostic("start_requested") if (shouldBeListening) return if (!speechRecognizer.isAvailable()) { @@ -146,6 +150,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe } shouldBeListening = true + audioDiagnostic("listening_requested") _state.value = SafeWordState.Arming val generation = ++lifecycleObserverGeneration dispatch_async(dispatch_get_main_queue()) { @@ -157,6 +162,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe } override fun stopListening() { + audioDiagnostic("stop_requested") shouldBeListening = false val generation = ++lifecycleObserverGeneration dispatch_async(dispatch_get_main_queue()) { @@ -217,6 +223,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe } private fun startRecognition() { + audioDiagnostic("recognition_start_requested") if (!shouldBeListening) return // F-039: a start attempt only becomes Armed once the audio engine is @@ -279,7 +286,12 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe @OptIn(BetaInteropApi::class) val engineStarted = memScoped { val errorPtr = alloc>() + errorPtr.value = null val started = engine.startAndReturnError(errorPtr.ptr) + IosAudioDiagnostics.record(TAG, "engine_start_result", warning = !started) { + "result=$started errorDomain=${errorPtr.value?.domain} errorCode=${errorPtr.value?.code} " + + "shouldBeListening=$shouldBeListening ${IosAudioDiagnostics.sessionDetails()}" + } if (!started) { val startError = errorPtr.value NSLog("$TAG: Audio engine failed to start: ${startError?.localizedDescription}") @@ -301,6 +313,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe } _state.value = armingTracker.onRecognizerReady() + audioDiagnostic("recognizer_ready") // fix(audit): H — do not log the configured safe word. It is user- // chosen and may be PII or a sensitive phrase. Log only a length // hint for debugging startup issues. @@ -313,6 +326,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe } private fun configureAudioSession() { + audioDiagnostic("configure_requested") try { val session = AVAudioSession.sharedInstance() // PlayAndRecord is required for the microphone tap. Without @@ -321,15 +335,22 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe // listener owns the process-wide session. MixWithOthers stays so // background audio keeps playing. The option only changes the // output route; input capture is unchanged. - session.setCategory( - AVAudioSessionCategoryPlayAndRecord, - AVAudioSessionCategoryOptionMixWithOthers or - AVAudioSessionCategoryOptionDefaultToSpeaker, - null, - ) - session.setMode(AVAudioSessionModeDefault, null) - session.setActive(true, null) + IosAudioDiagnostics.nativeResult(TAG, "set_category_play_and_record") { error -> + session.setCategory( + AVAudioSessionCategoryPlayAndRecord, + AVAudioSessionCategoryOptionMixWithOthers or + AVAudioSessionCategoryOptionDefaultToSpeaker, + error, + ) + } + IosAudioDiagnostics.nativeResult(TAG, "set_mode_default") { error -> + session.setMode(AVAudioSessionModeDefault, error) + } + IosAudioDiagnostics.nativeResult(TAG, "activate") { error -> + session.setActive(true, error) + } } catch (e: Exception) { + audioDiagnostic("configure_exception") NSLog("$TAG: Failed to configure audio session: ${e.message}") } } @@ -403,6 +424,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe } private fun tearDown() { + audioDiagnostic("teardown_requested") if (isTearingDown) return isTearingDown = true try { @@ -428,22 +450,27 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe // Deactivate audio session to release resources try { - AVAudioSession.sharedInstance().setActive( - false, - AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation, - null, - ) + IosAudioDiagnostics.nativeResult(TAG, "deactivate") { error -> + AVAudioSession.sharedInstance().setActive( + false, + AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation, + error, + ) + } } catch (e: Exception) { + audioDiagnostic("deactivate_exception") NSLog("$TAG: Error deactivating audio session: ${e.message}") } } catch (e: Exception) { NSLog("$TAG: Error stopping audio engine: ${e.message}") } finally { isTearingDown = false + audioDiagnostic("teardown_finished") } } private fun scheduleRestart() { + audioDiagnostic("restart_requested") if (!shouldBeListening) return tearDown() @@ -464,6 +491,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe * [shouldBeListening] is still true. */ private fun installLifecycleObservers() { + audioDiagnostic("observers_install_requested") if (lifecycleObserversInstalled) return lifecycleObserversInstalled = true val center = NSNotificationCenter.defaultCenter @@ -473,6 +501,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe `object` = null, queue = NSOperationQueue.mainQueue(), usingBlock = { _ -> + audioDiagnostic("app_active") restartRecognitionFromLifecycle("foreground") }, ) @@ -483,6 +512,11 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe usingBlock = { notification -> val typeValue = (notification?.userInfo?.get(AVAudioSessionInterruptionTypeKey) as? NSNumber) ?.unsignedLongLongValue + IosAudioDiagnostics.record(TAG, "interruption") { + "type=$typeValue options=${(notification?.userInfo?.get(AVAudioSessionInterruptionOptionKey) as? NSNumber)?.unsignedLongLongValue} " + + "wasSuspended=${(notification?.userInfo?.get(AVAudioSessionInterruptionWasSuspendedKey) as? NSNumber)?.boolValue} " + + "shouldBeListening=$shouldBeListening ${IosAudioDiagnostics.sessionDetails()}" + } if (typeValue == AVAudioSessionInterruptionTypeEnded) { restartRecognitionFromLifecycle("interruption-ended") } @@ -493,6 +527,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe } private fun removeLifecycleObservers() { + audioDiagnostic("observers_remove_requested") if (!lifecycleObserversInstalled) return lifecycleObserversInstalled = false lifecycleObservers.forEach { observer -> @@ -512,6 +547,7 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe * normal restart path). */ private fun restartRecognitionFromLifecycle(reason: String) { + audioDiagnostic("lifecycle_recovery_requested", "reason=$reason") if (!shouldBeListening) return NSLog("$TAG: Recovering speech recognition (reason=$reason)") lifecycleRecoveryCancellationGeneration = recognitionCallbackGeneration @@ -520,4 +556,12 @@ internal class IosSafeWordListener(private val safeWord: String) : SafeWordListe } private fun AVAudioFormat.isValidForRecordingTap(): Boolean = sampleRate > 0.0 && channelCount > 0u + + private fun audioDiagnostic(event: String, detail: String = "") { + IosAudioDiagnostics.record(TAG, event) { + "shouldBeListening=$shouldBeListening isTearingDown=$isTearingDown " + + "observersInstalled=$lifecycleObserversInstalled generation=$recognitionCallbackGeneration " + + "$detail ${IosAudioDiagnostics.sessionDetails()}" + } + } } diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/presentation/components/HapticFeedbackEffect.ios.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/presentation/components/HapticFeedbackEffect.ios.kt index 8b7ec5740..e4d9eb73f 100644 --- a/shared/src/iosMain/kotlin/com/devil/phoenixproject/presentation/components/HapticFeedbackEffect.ios.kt +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/presentation/components/HapticFeedbackEffect.ios.kt @@ -1,4 +1,4 @@ -@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) package com.devil.phoenixproject.presentation.components @@ -10,6 +10,7 @@ import co.touchlab.kermit.Logger import com.devil.phoenixproject.domain.model.HapticEvent import com.devil.phoenixproject.domain.model.VulgarTier import com.devil.phoenixproject.presentation.manager.ExerciseCountdownCuePolicy +import com.devil.phoenixproject.util.IosAudioDiagnostics import kotlinx.coroutines.flow.SharedFlow import platform.AVFAudio.AVAudioPlayer import platform.AVFAudio.AVAudioSession @@ -17,8 +18,14 @@ import platform.AVFAudio.AVAudioSessionCategoryAmbient import platform.AVFAudio.AVAudioSessionCategoryOptionMixWithOthers import platform.AVFAudio.AVAudioSessionCategoryPlayAndRecord import platform.AVFAudio.AVAudioSessionInterruptionNotification +import platform.AVFAudio.AVAudioSessionInterruptionOptionKey import platform.AVFAudio.AVAudioSessionInterruptionTypeEnded import platform.AVFAudio.AVAudioSessionInterruptionTypeKey +import platform.AVFAudio.AVAudioSessionInterruptionWasSuspendedKey +import platform.AVFAudio.AVAudioSessionMediaServicesWereLostNotification +import platform.AVFAudio.AVAudioSessionMediaServicesWereResetNotification +import platform.AVFAudio.AVAudioSessionRouteChangeNotification +import platform.AVFAudio.AVAudioSessionRouteChangeReasonKey import platform.AVFAudio.AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation import platform.AVFAudio.setActive import platform.Foundation.NSBundle @@ -26,6 +33,8 @@ import platform.Foundation.NSNotificationCenter import platform.Foundation.NSNumber import platform.Foundation.NSOperationQueue import platform.UIKit.UIApplicationDidBecomeActiveNotification +import platform.UIKit.UIApplicationDidEnterBackgroundNotification +import platform.UIKit.UIApplicationWillResignActiveNotification import platform.UIKit.UIImpactFeedbackGenerator import platform.UIKit.UIImpactFeedbackStyle import platform.UIKit.UINotificationFeedbackGenerator @@ -33,6 +42,7 @@ import platform.UIKit.UINotificationFeedbackType import platform.darwin.NSObjectProtocol private val log = Logger.withTag("HapticFeedbackEffect.ios") +private const val AUDIO_DIAGNOSTIC_TAG = "IosWorkoutAudio" /** * iOS implementation of HapticFeedbackEffect using UIKit haptic generators @@ -55,11 +65,17 @@ actual fun HapticFeedbackEffect(hapticEvents: SharedFlow) { } LaunchedEffect(hapticEvents) { - hapticEvents.collect { event -> - // Play haptic feedback - playHapticFeedback(event) - // Play sound (if available) - soundPlayers.playSound(event) + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "collector_start") + try { + hapticEvents.collect { event -> + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "cue_received") { "cue=${event::class.simpleName}" } + // Play haptic feedback + playHapticFeedback(event) + // Play sound (if available) + soundPlayers.playSound(event) + } + } finally { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "collector_stop") } } } @@ -88,6 +104,7 @@ private class IosSoundManager { private var released = false init { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "manager_init") setupAudioSession() loadSounds() loadBadgeSounds() @@ -99,6 +116,13 @@ private class IosSoundManager { loadEncouragementSounds("vulgar_strong", 12, encouragementStrongSoundPlayers, "vulgar strong sounds") loadEncouragementSounds("dominatrix", 12, encouragementDominatrixSoundPlayers, "dominatrix sounds") installLifecycleObservers() + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "players_loaded") { + "eventPlayers=${players.values.count { it != null }} badge=${badgeSoundPlayers.count { it != null }} " + + "pr=${prSoundPlayers.count { it != null }} rep=${repCountSoundPlayers.count { it != null }} " + + "countdown=${countdownTickPlayer != null} neutral=${encouragementNeutralSoundPlayers.size} " + + "mild=${encouragementMildSoundPlayers.size} strong=${encouragementStrongSoundPlayers.size} " + + "dominatrix=${encouragementDominatrixSoundPlayers.size} ${IosAudioDiagnostics.sessionDetails()}" + } } private fun setupAudioSession() { @@ -109,14 +133,23 @@ private class IosSoundManager { // SafeWordListener. AVAudioSession is process-wide; switching back // to Ambient during a workout would break microphone input. if (session.category != AVAudioSessionCategoryPlayAndRecord) { - session.setCategory( - AVAudioSessionCategoryAmbient, - AVAudioSessionCategoryOptionMixWithOthers, - null, - ) + IosAudioDiagnostics.nativeResult(AUDIO_DIAGNOSTIC_TAG, "set_category_ambient") { error -> + session.setCategory( + AVAudioSessionCategoryAmbient, + AVAudioSessionCategoryOptionMixWithOthers, + error, + ) + } + } else { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "preserve_play_and_record") { + IosAudioDiagnostics.sessionDetails() + } + } + IosAudioDiagnostics.nativeResult(AUDIO_DIAGNOSTIC_TAG, "activate") { error -> + session.setActive(true, error) } - session.setActive(true, null) } catch (e: Exception) { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "session_setup_exception", warning = true) log.w { "Failed to setup audio session: ${e.message}" } } } @@ -139,6 +172,9 @@ private class IosSoundManager { `object` = null, queue = NSOperationQueue.mainQueue(), usingBlock = { _ -> + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "app_active") { + "released=$released ${IosAudioDiagnostics.sessionDetails()}" + } recoverAudioSession() }, ) @@ -150,6 +186,11 @@ private class IosSoundManager { val userInfo = notification?.userInfo val typeValue = (userInfo?.get(AVAudioSessionInterruptionTypeKey) as? NSNumber) ?.unsignedLongLongValue + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "interruption") { + "type=$typeValue options=${(userInfo?.get(AVAudioSessionInterruptionOptionKey) as? NSNumber)?.unsignedLongLongValue} " + + "wasSuspended=${(userInfo?.get(AVAudioSessionInterruptionWasSuspendedKey) as? NSNumber)?.boolValue} " + + "released=$released ${IosAudioDiagnostics.sessionDetails()}" + } // Only react to "ended" — "began" means the system has already // suspended playback, and we will be re-foregrounded / re-shown // when the interruption ends. @@ -160,9 +201,34 @@ private class IosSoundManager { ) lifecycleObservers += foregroundObserver lifecycleObservers += interruptionObserver + // Observation only: these notifications do not add recovery or change session ownership. + listOf( + UIApplicationWillResignActiveNotification to "app_resign_active", + UIApplicationDidEnterBackgroundNotification to "app_background", + AVAudioSessionRouteChangeNotification to "route_change", + AVAudioSessionMediaServicesWereLostNotification to "media_services_lost", + AVAudioSessionMediaServicesWereResetNotification to "media_services_reset", + ).forEach { (name, event) -> + try { + lifecycleObservers += center.addObserverForName( + name = name, + `object` = null, + queue = NSOperationQueue.mainQueue(), + usingBlock = { notification -> + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, event) { + "released=$released routeReason=${(notification?.userInfo?.get(AVAudioSessionRouteChangeReasonKey) as? NSNumber)?.unsignedLongLongValue} " + + IosAudioDiagnostics.sessionDetails() + } + }, + ) + } catch (_: Exception) { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "diagnostic_observer_failed", warning = true) { "event=$event" } + } + } } private fun recoverAudioSession() { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "recovery_requested") { "released=$released" } if (released) return setupAudioSession() prepareAllPlayers() @@ -342,7 +408,10 @@ private class IosSoundManager { fun playSound(event: HapticEvent) { // ERROR event has no sound - if (event is HapticEvent.ERROR) return + if (event is HapticEvent.ERROR) { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "cue_has_no_sound") { "cue=ERROR" } + return + } val player = when (event) { is HapticEvent.BADGE_EARNED -> { @@ -415,21 +484,31 @@ private class IosSoundManager { // path does not repeatedly reconfigure the process-wide // AVAudioSession or downgrade SafeWordListener's PlayAndRecord // category during active workouts. - player.prepareToPlay() + val prepared = player.prepareToPlay() // Reset to beginning if already playing player.currentTime = 0.0 if (event is HapticEvent.COUNTDOWN_TICK) { player.enableRate = true player.rate = ExerciseCountdownCuePolicy.playbackRate(event.secondsRemaining) } - player.play() + val played = player.play() + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "player_result", warning = !prepared || !played) { + "cue=${event::class.simpleName} prepared=$prepared played=$played isPlaying=${player.isPlaying()} " + + "playerVolume=${player.volume} released=$released ${IosAudioDiagnostics.sessionDetails()}" + } } catch (e: Exception) { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "player_exception", warning = true) { "cue=${event::class.simpleName}" } log.w { "Sound playback failed for $event: ${e.message}" } } + } else { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "player_missing", warning = true) { + "cue=${event::class.simpleName} released=$released ${IosAudioDiagnostics.sessionDetails()}" + } } } fun release() { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "manager_release") // Issue #522: Prevent late-arriving foreground / interruption // notifications from re-activating the session after we have already // torn it down, and remove the observers we installed in init. @@ -490,12 +569,15 @@ private class IosSoundManager { // stepping on an active record session (mirrors the setup-time guard). val session = AVAudioSession.sharedInstance() if (session.category != AVAudioSessionCategoryPlayAndRecord) { - session.setActive( - false, - AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation, - null, - ) + IosAudioDiagnostics.nativeResult(AUDIO_DIAGNOSTIC_TAG, "deactivate") { error -> + session.setActive( + false, + AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation, + error, + ) + } } else { + IosAudioDiagnostics.record(AUDIO_DIAGNOSTIC_TAG, "deactivation_skipped_play_and_record") log.d { "Skipping audio-session deactivation: playAndRecord is active (safe-word listener owns it)" } } } catch (e: Exception) { diff --git a/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/IosAudioDiagnostics.kt b/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/IosAudioDiagnostics.kt new file mode 100644 index 000000000..8dfacc60a --- /dev/null +++ b/shared/src/iosMain/kotlin/com/devil/phoenixproject/util/IosAudioDiagnostics.kt @@ -0,0 +1,60 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.BetaInteropApi::class) + +package com.devil.phoenixproject.util + +import com.devil.phoenixproject.data.repository.ConnectionLogRepository +import com.devil.phoenixproject.data.repository.LogEventType +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionPortDescription +import platform.AVFAudio.currentRoute +import platform.AVFAudio.outputVolume +import platform.Foundation.NSError + +/** Bounded, exportable observations only; native success does not establish audible output. */ +internal object IosAudioDiagnostics { + fun record(owner: String, event: String, warning: Boolean = false, details: () -> String = { "" }) { + try { + val repository = ConnectionLogRepository.instance + if (!repository.isEnabled.value) return + val detail = details() + if (warning) { + repository.warning(LogEventType.WORKOUT_AUDIO, event, details = detail, tag = owner) + } else { + repository.debug(LogEventType.WORKOUT_AUDIO, event, details = detail, tag = owner) + } + } catch (_: Exception) { + // Diagnostics must not interrupt playback, recovery, or cancellation. + } + } + + fun sessionDetails(): String { + val session = AVAudioSession.sharedInstance() + val outputTypes = session.currentRoute.outputs.mapNotNull { + (it as? AVAudioSessionPortDescription)?.portType + }.joinToString(",") + // Port names/UIDs and NSError descriptions/userInfo can contain personal data. + return "category=${session.category} mode=${session.mode} options=${session.categoryOptions} " + + "outputVolume=${session.outputVolume} outputTypes=[$outputTypes]" + } + + /** Execute the original operation once, allowing its exception to follow the original path. */ + fun nativeResult( + owner: String, + operation: String, + call: (CPointer>) -> Boolean, + ): Boolean = memScoped { + val error = alloc>() + error.value = null + val result = call(error.ptr) + record(owner, operation, warning = !result) { + "result=$result errorDomain=${error.value?.domain} errorCode=${error.value?.code} ${sessionDetails()}" + } + result + } +} From 76863c2a056a6d638f042ff0437354f62a097c47 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 29 Oct 2026 19:29:25 -0400 Subject: [PATCH 3/3] fix(ci): scope Kable test opt-ins to platform versions --- shared-tvos/build.gradle.kts | 10 ++++++++++ shared/build.gradle.kts | 12 ++++++++++++ .../phoenixproject/data/ble/CompositionGattFake.kt | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/shared-tvos/build.gradle.kts b/shared-tvos/build.gradle.kts index 55ce49335..24f8fc118 100644 --- a/shared-tvos/build.gradle.kts +++ b/shared-tvos/build.gradle.kts @@ -92,6 +92,16 @@ kotlin { } } } + + // CompositionGattFake uses the vendored Kable 0.43.1 marker on tvOS. + // Apply it to the test compiler; commonTest opt-ins are not propagated here. + targets.withType().configureEach { + compilations.named("test") { + compileTaskProvider.configure { + compilerOptions.optIn.add("com.juul.kable.ExperimentalApi") + } + } + } } val phoneShared = project(":shared") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 08a4877a4..7bdab597f 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -152,6 +152,8 @@ kotlin { } getByName("androidHostTest") { + // CompositionGattFake uses the vendored Android Kable 0.43.1 API. + languageSettings.optIn("com.juul.kable.ExperimentalApi") dependencies { implementation(libs.junit) implementation(libs.mockk) @@ -240,6 +242,16 @@ kotlin { } } } + + // Upstream Kable 0.45 renamed the marker. Configure the native test compiler + // directly: an opt-in on the intermediate iosTest source set is not propagated. + targets.withType().configureEach { + compilations.named("test") { + compileTaskProvider.configure { + compilerOptions.optIn.add("com.juul.kable.ExperimentalKableApi") + } + } + } } sqldelight { diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/CompositionGattFake.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/CompositionGattFake.kt index 8c8666130..c997e565f 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/CompositionGattFake.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/ble/CompositionGattFake.kt @@ -1,4 +1,4 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class, com.juul.kable.ExperimentalApi::class) +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) package com.devil.phoenixproject.data.ble