Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 30 additions & 17 deletions androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
}
Expand Down Expand Up @@ -136,22 +142,19 @@ val localPropsMap: Map<String, String> = 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)
}
Expand Down Expand Up @@ -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=<int> (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=<int> (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.",
)
}
}
}
}
Expand Down
75 changes: 75 additions & 0 deletions docs/qa/issue-1357-ios-audio.md
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 90 additions & 0 deletions docs/qa/issue-1360-rest-controls.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 20 additions & 20 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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]
Expand Down Expand Up @@ -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" }
Expand Down
5 changes: 2 additions & 3 deletions gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions shared-tvos/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
compilations.named("test") {
compileTaskProvider.configure {
compilerOptions.optIn.add("com.juul.kable.ExperimentalApi")
}
}
}
}

val phoneShared = project(":shared")
Expand Down
Loading
Loading